youtube module

This commit is contained in:
renhaoting 2025-11-25 17:08:29 +08:00
parent 8b1add722e
commit be16d57958
65 changed files with 3207 additions and 3 deletions

View File

@ -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)

View File

@ -14,7 +14,7 @@ android {
buildTypes {
release {
minifyEnabled false
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}

View File

@ -19,5 +19,7 @@ dependencyResolutionManagement {
rootProject.name = "VidiDin-android"
apply from: "./core/core.includes.gradle"
apply from: "./youtube/youtube.includes.gradle"
include ':app'

1
youtube/core/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

44
youtube/core/build.gradle Normal file
View File

@ -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"
}

View File

@ -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() {
}
}

View File

@ -0,0 +1,7 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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<YouTubePlayerListener>
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
}
}
}

View File

@ -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<Long, BooleanProvider>()
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)
}
}
}

View File

@ -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) }
}

View File

@ -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) {}
}

View File

@ -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()
}

View File

@ -0,0 +1,7 @@
package com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners
import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer
interface YouTubePlayerCallback {
fun onYouTubePlayer(youTubePlayer: YouTubePlayer)
}

View File

@ -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. <br></br><br></br>
* 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)
}

View File

@ -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")
}
}
}
}

View File

@ -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<Listener>()
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))
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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<YouTubePlayerCallback>()
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
}
}

View File

@ -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)
}
}

View File

@ -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<YouTubePlayerListener>()
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<YouTubePlayerListener> = 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<YouTubePlayerListener> 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("<<injectedVideoId>>", if (videoId != null) { "'$videoId'" } else { "undefined" })
.replace("<<injectedPlayerVars>>", 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.")
}
}
}
}

View File

@ -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<FullscreenListener>()
/**
* 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
}
}
}

View File

@ -0,0 +1,194 @@
<!DOCTYPE html>
<html>
<style type="text/css">
html, body {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
background-color: #000000;
overflow: hidden;
position: fixed;
}
</style>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<!-- defer forces the library to execute after the html page is fully parsed. -->
<!-- This is needed to avoid race conditions, where the library executes and calls `onYouTubeIframeAPIReady` before the page is fully parsed. -->
<!-- See #873 on GitHub -->
<script defer src="https://www.youtube.com/iframe_api"></script>
</head>
<body>
<div id="youTubePlayerDOM"></div>
</body>
<script type="text/javascript">
var UNSTARTED = "UNSTARTED";
var ENDED = "ENDED";
var PLAYING = "PLAYING";
var PAUSED = "PAUSED";
var BUFFERING = "BUFFERING";
var CUED = "CUED";
var YouTubePlayerBridge = window.YouTubePlayerBridge;
var YouTubePlayerCallbacks = window.YouTubePlayerCallbacks;
var player;
var timerId;
function onYouTubeIframeAPIReady() {
YouTubePlayerBridge.sendYouTubeIFrameAPIReady();
var youtubePlayerConfig = {
height: '100%',
width: '100%',
events: {
onReady: function(event) { YouTubePlayerBridge.sendReady() },
onStateChange: function(event) { sendPlayerStateChange(event.data) },
onPlaybackQualityChange: function(event) { YouTubePlayerBridge.sendPlaybackQualityChange(event.data) },
onPlaybackRateChange: function(event) { YouTubePlayerBridge.sendPlaybackRateChange(event.data) },
onError: function(error) { YouTubePlayerBridge.sendError(error.data) },
onApiChange: function(event) { YouTubePlayerBridge.sendApiChange() }
},
playerVars: <<injectedPlayerVars>>
};
if (<<injectedVideoId>>) {
youtubePlayerConfig.videoId = <<injectedVideoId>>;
}
player = new YT.Player('youTubePlayerDOM', youtubePlayerConfig);
}
function sendPlayerStateChange(playerState) {
clearTimeout(timerId);
switch (playerState) {
case YT.PlayerState.UNSTARTED:
sendStateChange(UNSTARTED);
sendVideoIdFromPlaylistIfAvailable(player);
return;
case YT.PlayerState.ENDED:
sendStateChange(ENDED);
return;
case YT.PlayerState.PLAYING:
sendStateChange(PLAYING);
startSendCurrentTimeInterval();
sendVideoData(player);
return;
case YT.PlayerState.PAUSED:
sendStateChange(PAUSED);
return;
case YT.PlayerState.BUFFERING:
sendStateChange(BUFFERING);
return;
case YT.PlayerState.CUED:
sendStateChange(CUED);
return;
}
function sendVideoData(player) {
var videoDuration = player.getDuration();
YouTubePlayerBridge.sendVideoDuration(videoDuration);
}
// This method checks if the player is playing a playlist.
// If yes, it sends out the video id of the video being played.
function sendVideoIdFromPlaylistIfAvailable(player) {
var playlist = player.getPlaylist();
if ( typeof playlist !== 'undefined' && Array.isArray(playlist) && playlist.length > 0 ) {
var index = player.getPlaylistIndex();
var videoId = playlist[index];
YouTubePlayerBridge.sendVideoId(videoId);
}
}
function sendStateChange(newState) {
YouTubePlayerBridge.sendStateChange(newState)
}
function startSendCurrentTimeInterval() {
timerId = setInterval(function() {
YouTubePlayerBridge.sendVideoCurrentTime( player.getCurrentTime() )
YouTubePlayerBridge.sendVideoLoadedFraction( player.getVideoLoadedFraction() )
}, 100 );
}
}
// JAVA to WEB functions
function seekTo(startSeconds) {
player.seekTo(startSeconds, true);
}
function pauseVideo() {
player.pauseVideo();
}
function playVideo() {
player.playVideo();
}
function loadVideo(videoId, startSeconds) {
player.loadVideoById(videoId, startSeconds);
YouTubePlayerBridge.sendVideoId(videoId);
}
function cueVideo(videoId, startSeconds) {
player.cueVideoById(videoId, startSeconds);
YouTubePlayerBridge.sendVideoId(videoId);
}
function mute() {
player.mute();
}
function unMute() {
player.unMute();
}
function setVolume(volumePercent) {
player.setVolume(volumePercent);
}
function setPlaybackRate(playbackRate) {
player.setPlaybackRate(playbackRate);
}
function nextVideo() {
player.nextVideo();
}
function previousVideo() {
player.previousVideo();
}
function playVideoAt(index) {
player.playVideoAt(index);
}
function setLoop(loop) {
player.setLoop(loop);
}
function setShuffle(shuffle) {
player.setShuffle(shuffle);
}
function getMuteValue(requestId) {
var isMuted = player.isMuted();
YouTubePlayerCallbacks.sendBooleanValue(requestId, isMuted);
}
</script>
</html>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="YouTubePlayerView">
<attr name="enableAutomaticInitialization" format="boolean" />
<attr name="videoId" format="string" />
<attr name="autoPlay" format="boolean" />
<attr name="handleNetworkEvents" format="boolean" />
</declare-styleable>
</resources>

View File

@ -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

View File

@ -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 = "<div>some<span>fake</span>html</div>\n<div>some<span>fake</span>html</div>"
val inputStream = html.byteInputStream(StandardCharsets.UTF_8)
val parsedHtml = readHTMLFromUTF8File(inputStream)
assertEquals(parsedHtml, html)
}
}

1
youtube/custom-ui/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -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"
}

View File

21
youtube/custom-ui/proguard-rules.pro vendored Normal file
View File

@ -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

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@ -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)
}
}

View File

@ -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?
}

View File

@ -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
)

View File

@ -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
}

View File

@ -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<MenuItem>()
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<RecyclerView>(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
}
}

View File

@ -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<MenuItem>) :
RecyclerView.Adapter<MenuAdapter.ViewHolder>() {
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)
}
}

View File

@ -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) {}
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<public />
<public name="YouTubePlayerSeekBar_fontSize" type="attr" />
<public name="YouTubePlayerSeekBar_color" type="attr" />
<public name="YouTubePlayerView_enableAutomaticInitialization" type="attr" />
<public name="YouTubePlayerView_videoId" type="attr" />
<public name="YouTubePlayerView_autoPlay" type="attr" />
<public name="YouTubePlayerView_handleNetworkEvents" type="attr" />
</resources>

View File

@ -0,0 +1,4 @@
<ripple
xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/ayp_item_selected">
</ripple>

View File

@ -0,0 +1,4 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true" android:drawable="@color/ayp_item_selected" />
<item android:state_pressed="false" android:drawable="@android:color/transparent" />
</selector>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:startColor="@android:color/transparent"
android:endColor="@color/ayp_drop_shadow"
android:angle="270"/>
</shape>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:startColor="@android:color/transparent"
android:endColor="@color/ayp_drop_shadow"
android:angle="90"/>
</shape>

View File

@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#FFF" android:pathData="M5,5H10V7H7V10H5V5M14,5H19V10H17V7H14V5M17,14H19V19H14V17H17V14M10,17V19H5V14H7V17H10Z" />
</vector>

View File

@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#FFF" android:pathData="M14,14H19V16H16V19H14V14M5,14H10V19H8V16H5V14M8,5H10V10H5V8H8V5M19,8V10H14V5H16V8H19Z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFF"
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
</vector>

View File

@ -0,0 +1,4 @@
<vector android:height="36dp" android:viewportHeight="24.0"
android:viewportWidth="24.0" android:width="36dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#fff" android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FFF"
android:pathData="M8,5v14l11,-7z"/>
</vector>

View File

@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#FFF" android:pathData="M10,16.5V7.5L16,12M20,4.4C19.4,4.2 15.7,4 12,4C8.3,4 4.6,4.19 4,4.38C2.44,4.9 2,8.4 2,12C2,15.59 2.44,19.1 4,19.61C4.6,19.81 8.3,20 12,20C15.7,20 19.4,19.81 20,19.61C21.56,19.1 22,15.59 22,12C22,8.4 21.56,4.91 20,4.4Z" />
</vector>

View File

@ -0,0 +1,4 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@android:color/white" />
<corners android:radius="2dp" />
</shape>

View File

@ -0,0 +1,201 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<View
android:id="@+id/panel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="true"
android:focusable="true"
android:background="@android:color/black" />
<RelativeLayout
android:id="@+id/controls_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent" >
<View
android:id="@+id/drop_shadow_top"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_alignParentTop="true"
android:background="@drawable/ayp_drop_shadow_top" />
<View
android:id="@+id/drop_shadow_bottom"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_alignParentBottom="true"
android:background="@drawable/ayp_drop_shadow_bottom" />
<TextView
android:id="@+id/video_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:ellipsize="end"
android:textColor="@android:color/white"
android:textSize="14sp"
android:lines="1"
android:padding="8dp"
android:layout_alignParentTop="true"
android:layout_alignParentStart="true"
android:layout_toStartOf="@+id/extra_views_container" />
<LinearLayout
android:id="@+id/extra_views_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp"
android:layout_alignParentTop="true"
android:layout_alignParentEnd="true" >
<ImageView
android:id="@+id/menu_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ayp_ic_menu_24dp"
android:visibility="gone"
android:clickable="true"
android:focusable="true"
android:background="@drawable/ayp_background_item_selected"
android:contentDescription="@string/ayp_open_video_in_youtube" />
</LinearLayout>
<ImageView
android:id="@+id/play_pause_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ayp_ic_play_36dp"
android:visibility="invisible"
android:padding="8dp"
android:clickable="true"
android:focusable="true"
android:background="@drawable/ayp_background_item_selected"
android:layout_centerInParent="true"
android:contentDescription="@string/ayp_play_button" />
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_alignParentBottom="true"
android:layout_alignParentStart="true"
android:layout_alignParentEnd="true" >
<com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.views.YouTubePlayerSeekBar
android:id="@+id/youtube_player_seekbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
app:fontSize="12sp"
app:color="@color/ayp_red"
android:maxHeight="100dp" />
<TextView
android:id="@+id/live_video_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/ayp_live"
android:textColor="@android:color/white"
android:textSize="12sp"
android:padding="8dp"
android:gravity="center_vertical"
android:visibility="gone" />
<ImageView
android:id="@+id/youtube_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ayp_ic_youtube_24dp"
android:padding="8dp"
android:clickable="true"
android:focusable="true"
android:background="@drawable/ayp_background_item_selected"
android:contentDescription="@string/ayp_open_video_in_youtube" />
<ImageView
android:id="@+id/fullscreen_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ayp_ic_fullscreen_24dp"
android:padding="8dp"
android:clickable="true"
android:focusable="true"
android:background="@drawable/ayp_background_item_selected"
android:contentDescription="@string/ayp_full_screen_button" />
</LinearLayout>
<ImageView
android:id="@+id/custom_action_left_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ayp_ic_play_36dp"
android:visibility="gone"
android:padding="8dp"
android:layout_margin="32dp"
android:clickable="true"
android:focusable="true"
android:background="@drawable/ayp_background_item_selected"
android:contentDescription="@string/ayp_custom_action_left"
android:layout_centerVertical="true"
android:layout_toStartOf="@+id/play_pause_button" />
<ImageView
android:id="@+id/custom_action_right_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ayp_ic_play_36dp"
android:visibility="gone"
android:padding="8dp"
android:layout_margin="32dp"
android:clickable="true"
android:focusable="true"
android:background="@drawable/ayp_background_item_selected"
android:contentDescription="@string/ayp_custom_action_right"
android:layout_centerVertical="true"
android:layout_toEndOf="@+id/play_pause_button" />
</RelativeLayout>
</FrameLayout>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/ayp_menu_item_padding"
android:clickable="true"
android:focusable="true" >
<TextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="@dimen/ayp_menu_item_text_size"
android:textColor="@color/ayp_menu_text"
android:drawablePadding="12dp"
android:gravity="center" />
</LinearLayout>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/menu_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/ayp_menu_dialog_container_margin"
android:background="@drawable/ayp_shape_rounded_corners"
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="YouTubePlayerSeekBar">
<attr name="fontSize" format="dimension" />
<attr name="color" format="color" />
</declare-styleable>
</resources>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ayp_red">#F44336</color>
<color name="ayp_item_selected">#90FFFFFF</color>
<color name="ayp_drop_shadow">#66000000</color>
<color name="ayp_menu_icons">#757575</color>
<color name="ayp_menu_text">#222222</color>
</resources>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="ayp_8dp">8dp</dimen>
<dimen name="ayp_12sp">12sp</dimen>
<dimen name="ayp_menu_dialog_container_margin">8dp</dimen>
<dimen name="ayp_menu_item_padding">12dp</dimen>
<dimen name="ayp_menu_item_text_size">16sp</dimen>
</resources>

View File

@ -0,0 +1,9 @@
<resources>
<string name="ayp_play_button">Play button</string>
<string name="ayp_null_time">0:00</string>
<string name="ayp_open_video_in_youtube">Open video in YouTube</string>
<string name="ayp_full_screen_button">Full screen button</string>
<string name="ayp_live">LIVE</string>
<string name="ayp_custom_action_left">YouTube player Custom action left</string>
<string name="ayp_custom_action_right">YouTube player custom action right</string>
</resources>

View File

@ -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',
]

View File

@ -0,0 +1,3 @@
include(":youtube:core")
include(":youtube:custom-ui")