diff --git a/app/build.gradle b/app/build.gradle index b5c68c4f..68c7e905 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -172,15 +172,16 @@ dependencies { implementation("com.caoccao.javet:javet-android:2.2.1") //Exoplayer - implementation 'com.google.android.exoplayer:exoplayer-core:2.19.1' - implementation 'com.google.android.exoplayer:exoplayer-dash:2.19.1' - implementation 'com.google.android.exoplayer:exoplayer-ui:2.19.1' - implementation 'com.google.android.exoplayer:exoplayer-hls:2.19.1' - implementation 'com.google.android.exoplayer:exoplayer-rtsp:2.19.1' - implementation 'com.google.android.exoplayer:exoplayer-smoothstreaming:2.19.1' - implementation 'com.google.android.exoplayer:exoplayer-transformer:2.19.1' + implementation 'androidx.media3:media3-exoplayer:1.2.0' + implementation 'androidx.media3:media3-exoplayer-dash:1.2.0' + implementation 'androidx.media3:media3-ui:1.2.0' + implementation 'androidx.media3:media3-exoplayer-hls:1.2.0' + implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.0' + implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.0' + implementation 'androidx.media3:media3-transformer:1.2.0' implementation 'androidx.navigation:navigation-fragment-ktx:2.7.5' implementation 'androidx.navigation:navigation-ui-ktx:2.7.5' + implementation 'androidx.media:media:1.7.0' //Other implementation 'org.jmdns:jmdns:3.5.1' diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 28057a51..e0f6e96b 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -6,28 +6,41 @@ import android.content.Intent import android.net.Uri import android.webkit.CookieManager import androidx.lifecycle.lifecycleScope -import com.futo.platformplayer.activities.* +import com.futo.platformplayer.activities.MainActivity +import com.futo.platformplayer.activities.ManageTabsActivity +import com.futo.platformplayer.activities.PolycentricHomeActivity +import com.futo.platformplayer.activities.PolycentricProfileActivity +import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.serializers.FlexibleBooleanSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer -import com.futo.platformplayer.states.* +import com.futo.platformplayer.states.StateAnnouncement +import com.futo.platformplayer.states.StateApp +import com.futo.platformplayer.states.StateBackup +import com.futo.platformplayer.states.StateCache +import com.futo.platformplayer.states.StateMeta +import com.futo.platformplayer.states.StatePayment +import com.futo.platformplayer.states.StatePolycentric +import com.futo.platformplayer.states.StateUpdate import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorageFileJson import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.fields.DropdownFieldOptionsId -import com.futo.platformplayer.views.fields.FormField import com.futo.platformplayer.views.fields.FieldForm +import com.futo.platformplayer.views.fields.FormField import com.futo.platformplayer.views.fields.FormFieldButton import com.futo.platformplayer.views.fields.FormFieldWarning import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.serialization.* -import kotlinx.serialization.json.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import java.io.File import java.time.OffsetDateTime @@ -428,6 +441,9 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.restart_after_connectivity_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_connectivity_after_a_loss, 12) @DropdownFieldOptionsId(R.array.restart_playback_after_loss) var restartPlaybackAfterConnectivityLoss: Int = 1; + + @FormField(R.string.full_screen_portrait, FieldForm.TOGGLE, R.string.allow_full_screen_portrait, 13) + var fullscreenPortrait: Boolean = false; } @FormField(R.string.comments, "group", R.string.comments_description, 6) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt index fb7f05dd..9bf35ad2 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt @@ -1,7 +1,7 @@ -@file:Suppress("DEPRECATION") - package com.futo.platformplayer.api.media.platforms.js.models.sources +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.HttpDataSource import com.caoccao.javet.values.V8Value import com.caoccao.javet.values.reference.V8ValueObject import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource @@ -11,8 +11,6 @@ import com.futo.platformplayer.engine.IV8PluginConfig import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.orNull import com.futo.platformplayer.views.video.datasources.JSHttpDataSource -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource -import com.google.android.exoplayer2.upstream.HttpDataSource abstract class JSSource { protected val _config: IV8PluginConfig; diff --git a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt index 93de4b05..f390012e 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/AirPlayCastingDevice.kt @@ -1,12 +1,17 @@ package com.futo.platformplayer.casting import android.os.Looper -import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.getConnectedSocket +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.toInetAddress -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import java.net.InetAddress import java.util.UUID @@ -18,7 +23,7 @@ class AirPlayCastingDevice : CastingDevice { override var usedRemoteAddress: InetAddress? = null; override var localAddress: InetAddress? = null; override val canSetVolume: Boolean get() = false; - override val canSetSpeed: Boolean get() = false; //TODO: Implement playback speed for AirPlay + override val canSetSpeed: Boolean get() = true; var addresses: Array? = null; var port: Int = 0; @@ -59,6 +64,10 @@ class AirPlayCastingDevice : CastingDevice { } else { post("play", "text/parameters", "Content-Location: $contentId\r\nStart-Position: 0"); } + + if (speed != null) { + changeSpeed(speed) + } } override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) { @@ -186,6 +195,11 @@ class AirPlayCastingDevice : CastingDevice { _scopeIO = null; } + override fun changeSpeed(speed: Double) { + this.speed = speed + post("rate?value=$speed") + } + override fun getDeviceInfo(): CastingDeviceInfo { return CastingDeviceInfo(name!!, CastProtocolType.AIRPLAY, addresses!!.filter { a -> a.hostAddress != null }.map { a -> a.hostAddress!! }.toTypedArray(), port); } diff --git a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt index f7e0438f..beeeecc5 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/CastingDevice.kt @@ -81,7 +81,7 @@ abstract class CastingDevice { var speed: Double = 1.0 set(value) { val changed = value != field; - speed = value; + field = value; if (changed) { onSpeedChanged.emit(value); } diff --git a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt index 2a6d51c3..4df07285 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/FCastCastingDevice.kt @@ -3,14 +3,24 @@ package com.futo.platformplayer.casting import android.os.Looper import android.util.Log import com.futo.platformplayer.UIDialogs -import com.futo.platformplayer.casting.models.* -import com.futo.platformplayer.logging.Logger +import com.futo.platformplayer.casting.models.FCastPlayMessage +import com.futo.platformplayer.casting.models.FCastPlaybackErrorMessage +import com.futo.platformplayer.casting.models.FCastPlaybackUpdateMessage +import com.futo.platformplayer.casting.models.FCastSeekMessage +import com.futo.platformplayer.casting.models.FCastSetSpeedMessage +import com.futo.platformplayer.casting.models.FCastSetVolumeMessage +import com.futo.platformplayer.casting.models.FCastVersionMessage +import com.futo.platformplayer.casting.models.FCastVolumeUpdateMessage import com.futo.platformplayer.getConnectedSocket +import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.toHexString import com.futo.platformplayer.toInetAddress -import kotlinx.coroutines.* -import kotlinx.serialization.decodeFromString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.DataInputStream @@ -89,6 +99,8 @@ class FCastCastingDevice : CastingDevice { time = resumePosition, speed = speed )); + + this.speed = speed ?: 1.0 } override fun loadContent(contentType: String, content: String, resumePosition: Double, duration: Double, speed: Double?) { @@ -110,6 +122,8 @@ class FCastCastingDevice : CastingDevice { time = resumePosition, speed = speed )); + + this.speed = speed ?: 1.0 } override fun changeVolume(volume: Double) { @@ -122,12 +136,12 @@ class FCastCastingDevice : CastingDevice { } override fun changeSpeed(speed: Double) { - if (invokeInIOScopeIfRequired({ changeSpeed(volume) })) { + if (invokeInIOScopeIfRequired({ changeSpeed(speed) })) { return; } this.speed = speed - sendMessage(Opcode.SET_SPEED, FCastSetSpeedMessage(volume)) + sendMessage(Opcode.SET_SPEED, FCastSetSpeedMessage(speed)) } override fun seekVideo(timeSeconds: Double) { @@ -247,7 +261,8 @@ class FCastCastingDevice : CastingDevice { val buffer = ByteArray(4096); Logger.i(TAG, "Started receiving."); - while (_scopeIO?.isActive == true) { + var exceptionOccurred = false; + while (_scopeIO?.isActive == true && !exceptionOccurred) { try { val inputStream = _inputStream ?: break; Log.d(TAG, "Receiving next packet..."); @@ -275,20 +290,25 @@ class FCastCastingDevice : CastingDevice { } try { - handleMessage(Opcode.values().first { it.value == opcode }, json); + handleMessage(Opcode.entries.first { it.value == opcode }, json); } catch (e:Throwable) { Logger.w(TAG, "Failed to handle message.", e); } } catch (e: java.net.SocketException) { Logger.e(TAG, "Socket exception while receiving.", e); - break; + exceptionOccurred = true; } catch (e: Throwable) { Logger.e(TAG, "Exception while receiving.", e); - break; + exceptionOccurred = true; } } - _socket?.close(); - Logger.i(TAG, "Socket disconnected."); + + try { + _socket?.close(); + Logger.i(TAG, "Socket disconnected."); + } catch (e: Throwable) { + Logger.e(TAG, "Failed to close socket.", e) + } connectionState = CastConnectionState.CONNECTING; Thread.sleep(3000); diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt index 9ecb7b9d..685ccdd0 100644 --- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt +++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt @@ -5,6 +5,7 @@ import android.content.Context import android.net.Uri import android.os.Looper import android.util.Base64 +import android.util.Log import com.futo.platformplayer.BuildConfig import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient @@ -67,6 +68,7 @@ class StateCasting { val onActiveDeviceTimeChanged = Event1(); var activeDevice: CastingDevice? = null; private val _client = ManagedHttpClient(); + var _resumeCastingDevice: CastingDeviceInfo? = null; val isCasting: Boolean get() = activeDevice != null; @@ -182,28 +184,42 @@ class StateCasting { val networkConfig = Json.decodeFromString(json) val tcpService = networkConfig.services.first { v -> v.type == 0 } - addRememberedDevice(CastingDeviceInfo( + val foundInfo = addRememberedDevice(CastingDeviceInfo( name = networkConfig.name, type = CastProtocolType.FCAST, addresses = networkConfig.addresses.toTypedArray(), port = tcpService.port )) - UIDialogs.toast(context,"FCast device '${networkConfig.name}' added") + connectDevice(deviceFromCastingDeviceInfo(foundInfo)) } fun onStop() { val ad = activeDevice ?: return; + _resumeCastingDevice = ad.getDeviceInfo() + Log.i(TAG, "_resumeCastingDevice set to '${ad.name}'") Logger.i(TAG, "Stopping active device because of onStop."); ad.stop(); } + fun onResume() { + val resumeCastingDevice = _resumeCastingDevice + if (resumeCastingDevice != null) { + connectDevice(deviceFromCastingDeviceInfo(resumeCastingDevice)) + _resumeCastingDevice = null + Log.i(TAG, "_resumeCastingDevice set to null onResume") + } + } + @Synchronized fun start(context: Context) { if (_started) return; _started = true; + Log.i(TAG, "_resumeCastingDevice set null start") + _resumeCastingDevice = null; + Logger.i(TAG, "CastingService starting..."); rememberedDevices.clear(); @@ -246,6 +262,7 @@ class StateCasting { try { jmDNS.removeServiceListener("_googlecast._tcp.local.", _chromecastServiceListener); jmDNS.removeServiceListener("_airplay._tcp", _airPlayServiceListener); + jmDNS.removeServiceListener("_fastcast._tcp.local.", _fastCastServiceListener); if (BuildConfig.DEBUG) { jmDNS.removeServiceTypeListener(_serviceTypeListener); @@ -335,15 +352,20 @@ class StateCasting { Logger.i(TAG, "Connect to device ${device.name}"); } - fun addRememberedDevice(deviceInfo: CastingDeviceInfo) { + fun addRememberedDevice(deviceInfo: CastingDeviceInfo): CastingDeviceInfo { val device = deviceFromCastingDeviceInfo(deviceInfo); - addRememberedDevice(device); + return addRememberedDevice(device); } - fun addRememberedDevice(device: CastingDevice) { - if (_storage.addDevice(device.getDeviceInfo())) { + fun addRememberedDevice(device: CastingDevice): CastingDeviceInfo { + val deviceInfo = device.getDeviceInfo() + val foundInfo = _storage.addDevice(deviceInfo) + if (foundInfo == deviceInfo) { rememberedDevices.add(device); + return foundInfo; } + + return foundInfo; } fun removeRememberedDevice(device: CastingDevice) { @@ -361,7 +383,7 @@ class StateCasting { action(); } - fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1): Boolean { + fun castIfAvailable(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, ms: Long = -1, speed: Double?): Boolean { val ad = activeDevice ?: return false; if (ad.connectionState != CastConnectionState.CONNECTED) { return false; @@ -382,23 +404,23 @@ class StateCasting { if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { if (ad is AirPlayCastingDevice) { Logger.i(TAG, "Casting as local HLS"); - castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition); + castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); } else { Logger.i(TAG, "Casting as local DASH"); - castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition); + castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition, speed); } } else { StateApp.instance.scope.launch(Dispatchers.IO) { try { if (ad is FCastCastingDevice) { Logger.i(TAG, "Casting as DASH direct"); - castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); + castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); } else if (ad is AirPlayCastingDevice) { Logger.i(TAG, "Casting as HLS indirect"); - castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); + castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); } else { Logger.i(TAG, "Casting as DASH indirect"); - castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); + castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition, speed); } } catch (e: Throwable) { Logger.e(TAG, "Failed to start casting DASH videoSource=${videoSource} audioSource=${audioSource}.", e); @@ -408,32 +430,32 @@ class StateCasting { } else { if (videoSource is IVideoUrlSource) { Logger.i(TAG, "Casting as singular video"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble(), null); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble(), speed); } else if (audioSource is IAudioUrlSource) { Logger.i(TAG, "Casting as singular audio"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble(), null); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble(), speed); } else if(videoSource is IHLSManifestSource) { if (ad is ChromecastCastingDevice) { Logger.i(TAG, "Casting as proxied HLS"); - castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition); + castProxiedHls(video, videoSource.url, videoSource.codec, resumePosition, speed); } else { Logger.i(TAG, "Casting as non-proxied HLS"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), null); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble(), speed); } } else if(audioSource is IHLSManifestAudioSource) { if (ad is ChromecastCastingDevice) { Logger.i(TAG, "Casting as proxied audio HLS"); - castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition); + castProxiedHls(video, audioSource.url, audioSource.codec, resumePosition, speed); } else { Logger.i(TAG, "Casting as non-proxied audio HLS"); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), null); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble(), speed); } } else if (videoSource is LocalVideoSource) { Logger.i(TAG, "Casting as local video"); - castLocalVideo(video, videoSource, resumePosition); + castLocalVideo(video, videoSource, resumePosition, speed); } else if (audioSource is LocalAudioSource) { Logger.i(TAG, "Casting as local audio"); - castLocalAudio(video, audioSource, resumePosition); + castLocalAudio(video, audioSource, resumePosition, speed); } else { var str = listOf( if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null, @@ -471,15 +493,7 @@ class StateCasting { return true; } - private fun castVideoIndirect() { - - } - - private fun castAudioIndirect() { - - } - - private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List { + private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; @@ -493,12 +507,12 @@ class StateCasting { ).withTag("cast"); Logger.i(TAG, "Casting local video (videoUrl: $videoUrl)."); - ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), null); + ad.loadVideo("BUFFERED", videoSource.container, videoUrl, resumePosition, video.duration.toDouble(), speed); return listOf(videoUrl); } - private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double) : List { + private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; @@ -512,12 +526,12 @@ class StateCasting { ).withTag("cast"); Logger.i(TAG, "Casting local audio (audioUrl: $audioUrl)."); - ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), null); + ad.loadVideo("BUFFERED", audioSource.container, audioUrl, resumePosition, video.duration.toDouble(), speed); return listOf(audioUrl); } - private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double): List { + private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List { val ad = activeDevice ?: return listOf() val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}" @@ -608,12 +622,12 @@ class StateCasting { ).withTag("castLocalHls") Logger.i(TAG, "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).") - ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), null) + ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed) return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl) } - private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List { + private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; @@ -654,12 +668,12 @@ class StateCasting { } Logger.i(TAG, "added new castLocalDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath)."); - ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), null); + ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed); return listOf(dashUrl, videoUrl, audioUrl, subtitleUrl); } - private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List { + private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; @@ -699,12 +713,12 @@ class StateCasting { val content = DashBuilder.generateOnDemandDash(videoSource, videoUrl, audioSource, audioUrl, subtitleSource, subtitlesUrl); Logger.i(TAG, "Direct dash cast to casting device (videoUrl: $videoUrl, audioUrl: $audioUrl)."); - ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), null); + ad.loadContent("application/dash+xml", content, resumePosition, video.duration.toDouble(), speed); return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: ""); } - private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, codec: String?, resumePosition: Double): List { + private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, codec: String?, resumePosition: Double, speed: Double?): List { _castServer.removeAllHandlers("castProxiedHlsMaster") val ad = activeDevice ?: return listOf(); @@ -825,7 +839,7 @@ class StateCasting { //ChromeCast is sometimes funky with resume position 0 val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 0.1 else resumePosition; - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble(), null); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble(), speed); return listOf(hlsUrl); } @@ -876,7 +890,7 @@ class StateCasting { } } - private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List { + private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"; val id = UUID.randomUUID(); @@ -999,12 +1013,12 @@ class StateCasting { ).withTag("castHlsIndirectMaster") Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath)."); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), null); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble(), speed); return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); } - private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List { + private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List { val ad = activeDevice ?: return listOf(); val proxyStreams = ad !is FCastCastingDevice; @@ -1074,7 +1088,7 @@ class StateCasting { } Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); - ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), null); + ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble(), speed); return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); } diff --git a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt index 7f2b20ee..fe1dad58 100644 --- a/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt +++ b/app/src/main/java/com/futo/platformplayer/downloads/VideoDownload.kt @@ -67,7 +67,7 @@ class VideoDownload { val videoEither: IPlatformVideo get() = videoDetails ?: video ?: throw IllegalStateException("Missing video?"); val id: PlatformID get() = videoEither.id val name: String get() = videoEither.name; - val thumbnail: String? get() = videoDetails?.thumbnails?.getHQThumbnail() ?: video?.thumbnails?.getHQThumbnail(); + val thumbnail: String? get() = videoDetails?.thumbnails?.getHQThumbnail(); var targetPixelCount: Long? = null; var targetBitrate: Long? = null; diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt index 4f1b087e..37c6669b 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt @@ -108,17 +108,22 @@ class VideoDetailFragment : MainFragment { if(Settings.instance.other.bypassRotationPrevention && orientation == OrientationManager.Orientation.PORTRAIT) changeOrientation(OrientationManager.Orientation.PORTRAIT); + if(lastOrientation == newOrientation) return; activity?.let { if (isFullscreen) { - if(newOrientation == OrientationManager.Orientation.REVERSED_LANDSCAPE && it.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) - changeOrientation(OrientationManager.Orientation.REVERSED_LANDSCAPE); - else if(newOrientation == OrientationManager.Orientation.LANDSCAPE && it.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) - changeOrientation(OrientationManager.Orientation.LANDSCAPE); - else if(Settings.instance.playback.isAutoRotate() && (newOrientation == OrientationManager.Orientation.PORTRAIT || newOrientation == OrientationManager.Orientation.REVERSED_PORTRAIT)) { - _viewDetail?.setFullscreen(false); + if (Settings.instance.playback.fullscreenPortrait) { + changeOrientation(newOrientation); + } else { + if(newOrientation == OrientationManager.Orientation.REVERSED_LANDSCAPE && it.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) + changeOrientation(OrientationManager.Orientation.REVERSED_LANDSCAPE); + else if(newOrientation == OrientationManager.Orientation.LANDSCAPE && it.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE) + changeOrientation(OrientationManager.Orientation.LANDSCAPE); + else if(Settings.instance.playback.isAutoRotate() && (newOrientation == OrientationManager.Orientation.PORTRAIT || newOrientation == OrientationManager.Orientation.REVERSED_PORTRAIT)) { + _viewDetail?.setFullscreen(false); + } } } else { @@ -326,6 +331,8 @@ class VideoDetailFragment : MainFragment { Logger.i(TAG, "Real orientation on boot ${realOrientation}, lastOrientation: ${lastOrientation}"); if(realOrientation != lastOrientation) onOrientationChanged(realOrientation); + + StateCasting.instance.onResume(); } override fun onPause() { super.onPause(); @@ -403,10 +410,14 @@ class VideoDetailFragment : MainFragment { private fun onFullscreenChanged(fullscreen : Boolean) { activity?.let { if (fullscreen) { - var orient = lastOrientation; - if(orient == OrientationManager.Orientation.PORTRAIT || orient == OrientationManager.Orientation.REVERSED_PORTRAIT) - orient = OrientationManager.Orientation.LANDSCAPE; - changeOrientation(orient); + if (Settings.instance.playback.fullscreenPortrait) { + changeOrientation(lastOrientation); + } else { + var orient = lastOrientation; + if(orient == OrientationManager.Orientation.PORTRAIT || orient == OrientationManager.Orientation.REVERSED_PORTRAIT) + orient = OrientationManager.Orientation.LANDSCAPE; + changeOrientation(orient); + } } else changeOrientation(OrientationManager.Orientation.PORTRAIT); diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt index e7bc360f..9cb27040 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt @@ -1,5 +1,3 @@ -@file:Suppress("DEPRECATION") - package com.futo.platformplayer.fragment.mainactivity.main import android.app.PictureInPictureParams @@ -32,6 +30,12 @@ import android.widget.LinearLayout import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.lifecycle.lifecycleScope +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.HttpDataSource +import androidx.media3.ui.PlayerControlView +import androidx.media3.ui.TimeBar import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition @@ -138,11 +142,6 @@ import com.futo.polycentric.core.ContentType import com.futo.polycentric.core.Models import com.futo.polycentric.core.Opinion import com.futo.polycentric.core.toURLInfoSystemLinkUrl -import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.Format -import com.google.android.exoplayer2.ui.PlayerControlView -import com.google.android.exoplayer2.ui.TimeBar -import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException import com.google.protobuf.ByteString import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -155,7 +154,6 @@ import java.time.OffsetDateTime import kotlin.math.abs import kotlin.math.roundToLong - class VideoDetailView : ConstraintLayout { private val TAG = "VideoDetailView" @@ -303,7 +301,7 @@ class VideoDetailView : ConstraintLayout { Pair(0, 10) //around live, try every 10 seconds ); - + @androidx.annotation.OptIn(UnstableApi::class) constructor(context: Context, attrs : AttributeSet? = null) : super(context, attrs) { inflate(context, R.layout.fragview_video_detail, this); @@ -312,7 +310,7 @@ class VideoDetailView : ConstraintLayout { _cast = findViewById(R.id.videodetail_cast); _player = findViewById(R.id.videodetail_player); _playerProgress = findViewById(R.id.videodetail_progress); - _timeBar = _playerProgress.findViewById(com.google.android.exoplayer2.ui.R.id.exo_progress); + _timeBar = _playerProgress.findViewById(androidx.media3.ui.R.id.exo_progress); _title = findViewById(R.id.videodetail_title); _subTitle = findViewById(R.id.videodetail_meta); _platform = findViewById(R.id.videodetail_platform); @@ -514,6 +512,8 @@ class VideoDetailView : ConstraintLayout { _player.onDatasourceError.subscribe(::onDataSourceError); _player.onNext.subscribe { nextVideo(true, true, true) }; _player.onPrevious.subscribe { prevVideo(true) }; + _cast.onPrevious.subscribe { prevVideo(true) }; + _cast.onNext.subscribe { nextVideo(true, true, true) }; _minimize_controls_play.setOnClickListener { handlePlay(); }; _minimize_controls_pause.setOnClickListener { handlePause(); }; @@ -576,7 +576,7 @@ class VideoDetailView : ConstraintLayout { _playerProgress.player = _player.exoPlayer?.player; _playerProgress.setProgressUpdateListener { position, _ -> StatePlayer.instance.updateMediaSessionPlaybackState(_player.exoPlayer?.getPlaybackStateCompat() ?: PlaybackStateCompat.STATE_NONE, position); - } + }; StatePlayer.instance.onQueueChanged.subscribe(this) { if(!_destroyed) { @@ -1361,11 +1361,9 @@ class VideoDetailView : ConstraintLayout { } } - StatePlayer.instance.startOrUpdateMediaSession(context, video); StatePlayer.instance.setCurrentlyPlaying(video); - if(video.isLive && video.live != null) { loadLiveChat(video); } @@ -1472,7 +1470,7 @@ class VideoDetailView : ConstraintLayout { _player.seekTo(resumePositionMs); } else - loadCurrentVideoCast(video, videoSource, audioSource, subtitleSource, resumePositionMs); + loadCurrentVideoCast(video, videoSource, audioSource, subtitleSource, resumePositionMs, Settings.instance.playback.getDefaultPlaybackSpeed().toDouble()); _lastVideoSource = videoSource; _lastAudioSource = audioSource; @@ -1486,17 +1484,17 @@ class VideoDetailView : ConstraintLayout { UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_load_media), ex); } } - private fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long) { + private fun loadCurrentVideoCast(video: IPlatformVideoDetails, videoSource: IVideoSource?, audioSource: IAudioSource?, subtitleSource: ISubtitleSource?, resumePositionMs: Long, speed: Double?) { Logger.i(TAG, "loadCurrentVideoCast(video=$video, videoSource=$videoSource, audioSource=$audioSource, resumePositionMs=$resumePositionMs)") - if(StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs)) { + if(StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, audioSource, subtitleSource, resumePositionMs, speed)) { _cast.setVideoDetails(video, resumePositionMs / 1000); setCastEnabled(true); - } - else throw IllegalStateException("Disconnected cast during loading"); + } else throw IllegalStateException("Disconnected cast during loading"); } //Events + @androidx.annotation.OptIn(UnstableApi::class) private fun onSourceChanged(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean){ Logger.i(TAG, "onSourceChanged(videoSource=$videoSource, audioSource=$audioSource, resume=$resume)") @@ -1532,7 +1530,7 @@ class VideoDetailView : ConstraintLayout { private var _didTriggerDatasourceError = false; private fun onDataSourceError(exception: Throwable) { Logger.e(TAG, "onDataSourceError", exception); - if(exception.cause != null && exception.cause is InvalidResponseCodeException && (exception.cause!! as InvalidResponseCodeException).responseCode == 403) { + if(exception.cause != null && exception.cause is HttpDataSource.InvalidResponseCodeException && (exception.cause!! as HttpDataSource.InvalidResponseCodeException).responseCode == 403) { val currentVideo = video if(currentVideo == null || currentVideo !is IPluginSourced) return; @@ -1579,6 +1577,11 @@ class VideoDetailView : ConstraintLayout { _overlay_quality_selector?.selectOption("video", _lastVideoSource); _overlay_quality_selector?.selectOption("audio", _lastAudioSource); _overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource); + val currentPlaybackRate = (if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate()) ?: 1.0 + _overlay_quality_selector?.groupItems?.firstOrNull { it is SlideUpMenuButtonList && it.id == "playback_rate" }?.let { + (it as SlideUpMenuButtonList).setSelected(currentPlaybackRate.toString()) + }; + _overlay_quality_selector?.show(); _slideUpOverlay = _overlay_quality_selector; } @@ -1611,6 +1614,7 @@ class VideoDetailView : ConstraintLayout { val v = video ?: return; updateQualitySourcesOverlay(v, videoLocal, liveStreamVideoFormats, liveStreamAudioFormats); } + @androidx.annotation.OptIn(UnstableApi::class) private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List? = null, liveStreamAudioFormats: List? = null) { Logger.i(TAG, "updateQualitySourcesOverlay"); @@ -1658,18 +1662,26 @@ class VideoDetailView : ConstraintLayout { ?.filter { it.container == bestAudioContainer } ?.toList() ?: listOf(); + val canSetSpeed = !_isCasting || StateCasting.instance.activeDevice?.canSetSpeed == true + val currentPlaybackRate = if (_isCasting) StateCasting.instance.activeDevice?.speed else _player.getPlaybackRate() _overlay_quality_selector = SlideUpMenuOverlay(this.context, _overlay_quality_container, context.getString( R.string.quality), null, true, - if (!_isCasting) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null, - if (!_isCasting) SlideUpMenuButtonList(this.context).apply { - setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), _player.getPlaybackRate().toString()); + if (canSetSpeed) SlideUpMenuTitle(this.context).apply { setTitle(context.getString(R.string.playback_rate)) } else null, + if (canSetSpeed) SlideUpMenuButtonList(this.context, null, "playback_rate").apply { + setButtons(listOf("0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25"), currentPlaybackRate!!.toString()); onClick.subscribe { v -> if (_isCasting) { - return@subscribe; - } + val ad = StateCasting.instance.activeDevice ?: return@subscribe + if (!ad.canSetSpeed) { + return@subscribe + } - _player.setPlaybackRate(v.toFloat()); - setSelected(v); + ad.changeSpeed(v.toDouble()) + setSelected(v); + } else { + _player.setPlaybackRate(v.toFloat()); + setSelected(v); + } }; } else null, @@ -1730,7 +1742,7 @@ class VideoDetailView : ConstraintLayout { { handleSelectAudioTrack(it) }); }.toList().toTypedArray()) else null, - if(video?.subtitles?.isNotEmpty() ?: false && video != null) + if(video?.subtitles?.isNotEmpty() == true) SlideUpMenuGroup(this.context, context.getString(R.string.subtitles), "subtitles", *video.subtitles .map { @@ -1831,7 +1843,7 @@ class VideoDetailView : ConstraintLayout { val d = StateCasting.instance.activeDevice; if (d != null && d.connectionState == CastConnectionState.CONNECTED) - StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong()); + StateCasting.instance.castIfAvailable(context.contentResolver, video, videoSource, _lastAudioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); else if(!_player.swapSources(videoSource, _lastAudioSource, true, true, true)) _player.hideControls(false); //TODO: Disable player? @@ -1846,7 +1858,7 @@ class VideoDetailView : ConstraintLayout { val d = StateCasting.instance.activeDevice; if (d != null && d.connectionState == CastConnectionState.CONNECTED) - StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong()); + StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, audioSource, _lastSubtitleSource, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); else(!_player.swapSources(_lastVideoSource, audioSource, true, true, true)) _player.hideControls(false); //TODO: Disable player? @@ -1862,7 +1874,7 @@ class VideoDetailView : ConstraintLayout { val d = StateCasting.instance.activeDevice; if (d != null && d.connectionState == CastConnectionState.CONNECTED) - StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong()); + StateCasting.instance.castIfAvailable(context.contentResolver, video, _lastVideoSource, _lastAudioSource, toSet, (d.expectedCurrentTime * 1000.0).toLong(), d.speed); else _player.swapSubtitles(fragment.lifecycleScope, toSet); diff --git a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt index f2866441..1625b1e4 100644 --- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt +++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt @@ -1,8 +1,14 @@ -@file:Suppress("DEPRECATION") - package com.futo.platformplayer.helpers import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.exoplayer.dash.DashMediaSource +import androidx.media3.exoplayer.dash.manifest.DashManifestParser +import androidx.media3.exoplayer.source.MediaSource import com.futo.platformplayer.api.media.models.streams.IVideoSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource @@ -16,11 +22,6 @@ import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlR import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.others.Language -import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.source.MediaSource -import com.google.android.exoplayer2.source.dash.DashMediaSource -import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser -import com.google.android.exoplayer2.upstream.ResolvingDataSource import kotlin.math.abs class VideoHelper { @@ -123,7 +124,7 @@ class VideoHelper { return bestSource; } - @Suppress("DEPRECATION") + @OptIn(UnstableApi::class) fun convertItagSourceToChunkedDashSource(videoSource: JSVideoUrlRangeSource) : MediaSource { val urlToUse = videoSource.getVideoUrl(); val manifestConfig = ProgressiveDashManifestCreator.fromVideoProgressiveStreamingUrl(urlToUse, @@ -142,14 +143,25 @@ class VideoHelper { ); val manifest = DashManifestParser().parse(Uri.parse(""), manifestConfig.byteInputStream()); - return DashMediaSource.Factory(ResolvingDataSource.Factory(videoSource.getHttpDataSourceFactory(), ResolvingDataSource.Resolver { dataSpec -> Logger.v("PLAYBACK", "Video REQ Range [" + dataSpec.position + "-" + (dataSpec.position + dataSpec.length) + "](" + dataSpec.length + ")", null); return@Resolver dataSpec; })).createMediaSource(manifest, MediaItem.Builder().setUri(Uri.parse(videoSource.getVideoUrl())).build()) } - @Suppress("DEPRECATION") + fun getMediaMetadata(media: IPlatformVideoDetails): MediaMetadata { + val builder = MediaMetadata.Builder() + .setArtist(media.author.name) + .setTitle(media.name) + + media.thumbnails.getHQThumbnail()?.let { + builder.setArtworkUri(Uri.parse(it)) + } + + return builder.build() + } + + @OptIn(UnstableApi::class) fun convertItagSourceToChunkedDashSource(audioSource: JSAudioUrlRangeSource) : MediaSource { val manifestConfig = ProgressiveDashManifestCreator.fromAudioProgressiveStreamingUrl(audioSource.getAudioUrl(), audioSource.duration?.times(1000) ?: 0, diff --git a/app/src/main/java/com/futo/platformplayer/receivers/InstallReceiver.kt b/app/src/main/java/com/futo/platformplayer/receivers/InstallReceiver.kt index 09e41150..9112b6d5 100644 --- a/app/src/main/java/com/futo/platformplayer/receivers/InstallReceiver.kt +++ b/app/src/main/java/com/futo/platformplayer/receivers/InstallReceiver.kt @@ -17,7 +17,6 @@ class InstallReceiver : BroadcastReceiver() { val onReceiveResult = Event1(); } - @Suppress("DEPRECATION") override fun onReceive(context: Context, intent: Intent) { val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1); Logger.i(TAG, "Received status $status."); diff --git a/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt b/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt index ba73594e..08075279 100644 --- a/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt +++ b/app/src/main/java/com/futo/platformplayer/services/MediaPlaybackService.kt @@ -55,6 +55,7 @@ class MediaPlaybackService : Service() { private var _hasFocus: Boolean = false; private var _focusRequest: AudioFocusRequest? = null; private var _audioFocusLossTime_ms: Long? = null + private var _playbackState = PlaybackStateCompat.STATE_NONE; override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Logger.v(TAG, "onStartCommand"); @@ -250,13 +251,13 @@ class MediaPlaybackService : Service() { .setSilent(true) .setContentIntent(PendingIntent.getActivity(this, 5, bringUpIntent, PendingIntent.FLAG_IMMUTABLE)) .setStyle(if(hasQueue) - androidx.media.app.NotificationCompat.MediaStyle() - .setMediaSession(session.sessionToken) - .setShowActionsInCompactView(0, 1, 2) - else - androidx.media.app.NotificationCompat.MediaStyle() - .setMediaSession(session.sessionToken) - .setShowActionsInCompactView(0)) + androidx.media.app.NotificationCompat.MediaStyle() + .setMediaSession(session.sessionToken) + .setShowActionsInCompactView(0, 1, 2) + else + androidx.media.app.NotificationCompat.MediaStyle() + .setMediaSession(session.sessionToken) + .setShowActionsInCompactView(0)) .setDeleteIntent(deleteIntent) .setChannelId(channel.id) @@ -306,10 +307,8 @@ class MediaPlaybackService : Service() { Logger.i(TAG, "Updating notification bitmap=${if (bitmap != null) "yes" else "no."} channelId=${channel.id} icon=${icon} video=${video?.name ?: ""} playWhenReady=${playWhenReady} session.sessionToken=${session.sessionToken}"); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // For API 29 and above startForeground(MEDIA_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK); } else { - // For API 28 and below startForeground(MEDIA_NOTIF_ID, notif); } @@ -319,19 +318,21 @@ class MediaPlaybackService : Service() { fun updateMediaSessionPlaybackState(state: Int, pos: Long) { _mediaSession?.setPlaybackState( PlaybackStateCompat.Builder() - .setActions( - PlaybackStateCompat.ACTION_SEEK_TO or - PlaybackStateCompat.ACTION_PLAY or - PlaybackStateCompat.ACTION_PAUSE or - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or - PlaybackStateCompat.ACTION_SKIP_TO_NEXT or - PlaybackStateCompat.ACTION_PLAY_PAUSE - ) - .setState(state, pos, 1f, SystemClock.elapsedRealtime()) - .build()); + .setActions( + PlaybackStateCompat.ACTION_SEEK_TO or + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_PLAY_PAUSE + ) + .setState(state, pos, 1f, SystemClock.elapsedRealtime()) + .build()); if(_focusRequest == null) setAudioFocus(); + + _playbackState = state; } //TODO: (TBD) This code probably more fitting inside FutoVideoPlayer, as this service is generally only used for global events @@ -379,14 +380,26 @@ class MediaPlaybackService : Service() { } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { MediaControlReceiver.onPauseReceived.emit(); - _audioFocusLossTime_ms = System.currentTimeMillis() + if (_playbackState != PlaybackStateCompat.STATE_PAUSED && + _playbackState != PlaybackStateCompat.STATE_STOPPED && + _playbackState != PlaybackStateCompat.STATE_NONE && + _playbackState != PlaybackStateCompat.STATE_ERROR) { + _audioFocusLossTime_ms = System.currentTimeMillis() + } + Log.i(TAG, "Audio focus transient loss"); } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { Log.i(TAG, "Audio focus transient loss, can duck"); } AudioManager.AUDIOFOCUS_LOSS -> { - _audioFocusLossTime_ms = System.currentTimeMillis() + if (_playbackState != PlaybackStateCompat.STATE_PAUSED && + _playbackState != PlaybackStateCompat.STATE_STOPPED && + _playbackState != PlaybackStateCompat.STATE_NONE && + _playbackState != PlaybackStateCompat.STATE_ERROR) { + _audioFocusLossTime_ms = System.currentTimeMillis() + } + _hasFocus = false; MediaControlReceiver.onPauseReceived.emit(); Log.i(TAG, "Audio focus lost"); diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt index e1243a8d..7d642ac4 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlayer.kt @@ -1,8 +1,12 @@ -@file:Suppress("DEPRECATION") - package com.futo.platformplayer.states import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.upstream.DefaultAllocator import com.futo.platformplayer.R import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails @@ -13,10 +17,6 @@ import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.models.Playlist import com.futo.platformplayer.services.MediaPlaybackService import com.futo.platformplayer.video.PlayerManager -import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.DefaultLoadControl -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.upstream.DefaultAllocator import kotlin.random.Random /*** @@ -557,21 +557,23 @@ class StatePlayer { } //Player Initialization - fun getPlayerOrCreate(context : Context) : PlayerManager { + fun getPlayerOrCreate(context: Context) : PlayerManager { if(_exoplayer == null) { val player = createExoPlayer(context); _exoplayer = PlayerManager(player); } return _exoplayer!!; } - fun getThumbnailPlayerOrCreate(context : Context) : PlayerManager { + fun getThumbnailPlayerOrCreate(context: Context) : PlayerManager { if(_thumbnailExoPlayer == null) { val player = createExoPlayer(context); _thumbnailExoPlayer = PlayerManager(player); } return _thumbnailExoPlayer!!; } - private fun createExoPlayer(context : Context) : ExoPlayer { + + @OptIn(UnstableApi::class) + private fun createExoPlayer(context : Context): ExoPlayer { return ExoPlayer.Builder(context) .setLoadControl( DefaultLoadControl.Builder() @@ -589,7 +591,6 @@ class StatePlayer { .build(); } - fun dispose(){ val player = _exoplayer; val thumbPlayer = _thumbnailExoPlayer; diff --git a/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt b/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt index 40d375b2..3ecff95a 100644 --- a/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt +++ b/app/src/main/java/com/futo/platformplayer/stores/CastingDeviceInfoStorage.kt @@ -20,10 +20,11 @@ class CastingDeviceInfoStorage : FragmentedStorageFileJson() { } @Synchronized - fun addDevice(castingDeviceInfo: CastingDeviceInfo): Boolean { - if (deviceInfos.any { d -> d.name == castingDeviceInfo.name }) { + fun addDevice(castingDeviceInfo: CastingDeviceInfo): CastingDeviceInfo { + val foundDeviceInfo = deviceInfos.firstOrNull { d -> d.name == castingDeviceInfo.name } + if (foundDeviceInfo != null) { Logger.i("CastingDeviceInfoStorage", "Device '${castingDeviceInfo.name}' already existed in device storage.") - return false; + return foundDeviceInfo; } if (deviceInfos.size >= 5) { @@ -32,7 +33,7 @@ class CastingDeviceInfoStorage : FragmentedStorageFileJson() { deviceInfos.add(castingDeviceInfo); save(); - return true; + return castingDeviceInfo; } @Synchronized diff --git a/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt b/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt index adce7f3a..22926841 100644 --- a/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt +++ b/app/src/main/java/com/futo/platformplayer/video/PlayerManager.kt @@ -1,15 +1,13 @@ -@file:Suppress("DEPRECATION") - package com.futo.platformplayer.video import android.media.session.PlaybackState import android.support.v4.media.session.PlaybackStateCompat -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.ui.StyledPlayerView +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView class PlayerManager { - private var _currentView: StyledPlayerView? = null; + private var _currentView: PlayerView? = null; private val _stateMap = HashMap(); private var _currentState: PlayerState? = null; val currentState: PlayerState get() { @@ -25,6 +23,7 @@ class PlayerManager { this.player = exoPlayer; } + fun getPlaybackStateCompat() : Int { return when(player.playbackState) { ExoPlayer.STATE_READY -> if(player.playWhenReady) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED; @@ -34,7 +33,7 @@ class PlayerManager { } @Synchronized - fun attach(view: StyledPlayerView, stateName: String) { + fun attach(view: PlayerView, stateName: String) { if(view != _currentView) { _currentView?.player = null; switchState(stateName); diff --git a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt index 535520a7..087586d3 100644 --- a/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt +++ b/app/src/main/java/com/futo/platformplayer/views/casting/CastView.kt @@ -1,5 +1,3 @@ -@file:Suppress("DEPRECATION") - package com.futo.platformplayer.views.casting import android.content.Context @@ -13,7 +11,11 @@ import android.widget.FrameLayout import android.widget.ImageButton import android.widget.ImageView import android.widget.TextView +import androidx.annotation.OptIn import androidx.constraintlayout.widget.ConstraintLayout +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.TimeBar import com.bumptech.glide.Glide import com.futo.platformplayer.R import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails @@ -23,9 +25,6 @@ import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.views.behavior.GestureControlView -import com.google.android.exoplayer2.ui.DefaultTimeBar -import com.google.android.exoplayer2.ui.TimeBar -import com.google.android.exoplayer2.ui.TimeBar.OnScrubListener import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -39,6 +38,8 @@ class CastView : ConstraintLayout { private val _buttonSettings: ImageButton; private val _buttonLoop: ImageButton; private val _buttonPlay: ImageButton; + private val _buttonPrevious: ImageButton; + private val _buttonNext: ImageButton; private val _buttonPause: ImageButton; private val _buttonCast: CastButton; private val _textPosition: TextView; @@ -53,7 +54,10 @@ class CastView : ConstraintLayout { val onMinimizeClick = Event0(); val onSettingsClick = Event0(); + val onPrevious = Event0(); + val onNext = Event0(); + @OptIn(UnstableApi::class) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { LayoutInflater.from(context).inflate(R.layout.view_cast, this, true); @@ -62,6 +66,8 @@ class CastView : ConstraintLayout { _buttonSettings = findViewById(R.id.button_settings); _buttonLoop = findViewById(R.id.button_loop); _buttonPlay = findViewById(R.id.button_play); + _buttonPrevious = findViewById(R.id.button_previous); + _buttonNext = findViewById(R.id.button_next); _buttonPause = findViewById(R.id.button_pause); _buttonCast = findViewById(R.id.button_cast); _textPosition = findViewById(R.id.text_position); @@ -83,7 +89,7 @@ class CastView : ConstraintLayout { } _buttonLoop.setImageResource(if(StatePlayer.instance.loopVideo) R.drawable.ic_loop_active else R.drawable.ic_loop); - _timeBar.addListener(object : OnScrubListener { + _timeBar.addListener(object : TimeBar.OnScrubListener { override fun onScrubStart(timeBar: TimeBar, position: Long) { StateCasting.instance.videoSeekTo(position.toDouble()); } @@ -105,6 +111,29 @@ class CastView : ConstraintLayout { if (!isInEditMode) { setIsPlaying(false); } + + StatePlayer.instance.onQueueChanged.subscribe(this) { + CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) { + setLoopVisible(!StatePlayer.instance.hasQueue) + updateNextPrevious(); + } + } + StatePlayer.instance.onVideoChanging.subscribe(this) { + CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) { + updateNextPrevious(); + } + } + + updateNextPrevious(); + _buttonPrevious.setOnClickListener { onPrevious.emit() }; + _buttonNext.setOnClickListener { onNext.emit() }; + } + + private fun updateNextPrevious() { + val vidPrev = StatePlayer.instance.getPrevQueueItem(true); + val vidNext = StatePlayer.instance.getNextQueueItem(true); + _buttonNext.visibility = if (vidNext != null) View.VISIBLE else View.GONE + _buttonPrevious.visibility = if (vidPrev != null) View.VISIBLE else View.GONE } fun stopTimeJob() { @@ -150,7 +179,6 @@ class CastView : ConstraintLayout { } val position = StateCasting.instance.activeDevice?.expectedCurrentTime?.times(1000.0)?.toLong(); - if(StatePlayer.instance.hasMediaSession()) { StatePlayer.instance.updateMediaSession(null); StatePlayer.instance.updateMediaSessionPlaybackState(getPlaybackStateCompat(), (position ?: 0)); @@ -183,6 +211,7 @@ class CastView : ConstraintLayout { } } + @OptIn(UnstableApi::class) fun setVideoDetails(video: IPlatformVideoDetails, position: Long) { Glide.with(_thumbnail) .load(video.thumbnails.getHQThumbnail()) @@ -194,6 +223,7 @@ class CastView : ConstraintLayout { _timeBar.setDuration(video.duration); } + @OptIn(UnstableApi::class) fun setTime(ms: Long) { _textPosition.text = ms.toHumanTime(true); _timeBar.setPosition(ms / 1000); diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuButtonList.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuButtonList.kt index f5a404fc..78031ec0 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuButtonList.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuButtonList.kt @@ -18,8 +18,11 @@ class SlideUpMenuButtonList : LinearLayout { val onClick = Event1(); val buttons: HashMap = hashMapOf(); var _activeText: String? = null; + val id: String? + + constructor(context: Context, attrs: AttributeSet? = null, id: String? = null): super(context, attrs) { + this.id = id - constructor(context: Context, attrs: AttributeSet? = null): super(context, attrs) { LayoutInflater.from(context).inflate(R.layout.overlay_slide_up_menu_button_list, this, true); _root = findViewById(R.id.root); diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt index 157c13a6..89cf1359 100644 --- a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt +++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuOverlay.kt @@ -26,7 +26,7 @@ class SlideUpMenuOverlay : RelativeLayout { private lateinit var _viewContainer: LinearLayout; private var _animated: Boolean = true; - private var _groupItems: List; + var groupItems: List; var isVisible = false private set; @@ -36,7 +36,7 @@ class SlideUpMenuOverlay : RelativeLayout { constructor(context: Context, attrs: AttributeSet? = null): super(context, attrs) { init(false, null); - _groupItems = listOf(); + groupItems = listOf(); } constructor(context: Context, parent: ViewGroup, titleText: String, okText: String?, animated: Boolean, items: List, hideButtons: Boolean = false): super(context){ @@ -47,7 +47,7 @@ class SlideUpMenuOverlay : RelativeLayout { _container!!.addView(this); } _textTitle.text = titleText; - _groupItems = items; + groupItems = items; if(hideButtons) { _textCancel.visibility = GONE; @@ -74,7 +74,7 @@ class SlideUpMenuOverlay : RelativeLayout { item.setParentClickListener { hide() }; } - _groupItems = items; + groupItems = items; } private fun init(animated: Boolean, okText: String?){ @@ -116,12 +116,12 @@ class SlideUpMenuOverlay : RelativeLayout { fun selectOption(groupTag: Any?, itemTag: Any?, multiSelect: Boolean = false, toggle: Boolean = false): Boolean { var didSelect = false; - for(view in _groupItems) { + for(view in groupItems) { if(view is SlideUpMenuGroup && view.groupTag == groupTag) didSelect = didSelect || view.selectItem(itemTag); } if(groupTag == null) - for(item in _groupItems) + for(item in groupItems) if(item is SlideUpMenuItem) { if(multiSelect) { if(item.itemTag == itemTag) diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt index f4701155..453c5ed0 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoThumbnailPlayer.kt @@ -1,5 +1,3 @@ -@file:Suppress("DEPRECATION") - package com.futo.platformplayer.views.video import android.content.Context @@ -9,6 +7,10 @@ import android.view.View import android.widget.ImageButton import android.widget.LinearLayout import android.widget.TextView +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.PlayerControlView +import androidx.media3.ui.PlayerView import com.futo.platformplayer.R import com.futo.platformplayer.Settings import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource @@ -17,8 +19,6 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.toHumanTime import com.futo.platformplayer.video.PlayerManager -import com.google.android.exoplayer2.ui.PlayerControlView -import com.google.android.exoplayer2.ui.StyledPlayerView class FutoThumbnailPlayer : FutoVideoPlayerBase { @@ -28,7 +28,7 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase { } //Views - private val videoView : StyledPlayerView; + private val videoView : PlayerView; private val videoControls : PlayerControlView; private val buttonMute : ImageButton; private val buttonUnMute : ImageButton; @@ -41,7 +41,8 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase { private val _evMuteChanged = mutableListOf<(FutoThumbnailPlayer, Boolean)->Unit>(); - constructor(context : Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) { + @OptIn(UnstableApi::class) + constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) { LayoutInflater.from(context).inflate(R.layout.thumbnail_video_view, this, true); videoView = findViewById(R.id.video_player); @@ -70,7 +71,7 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase { } } - fun setLive(live : Boolean) { + fun setLive(live: Boolean) { if(live) { containerDuration.visibility = GONE; containerLive.visibility = VISIBLE; @@ -81,7 +82,8 @@ class FutoThumbnailPlayer : FutoVideoPlayerBase { } } - fun setPlayer(player : PlayerManager?){ + @OptIn(UnstableApi::class) + fun setPlayer(player: PlayerManager?){ changePlayer(player); player?.attach(videoView, PLAYER_STATE_NAME); videoControls.player = player?.player; diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt index d306b090..ad606f26 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt @@ -1,5 +1,3 @@ -@file:Suppress("DEPRECATION") - package com.futo.platformplayer.views.video import android.content.Context @@ -15,6 +13,7 @@ import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout import android.widget.ImageButton import android.widget.TextView +import androidx.annotation.OptIn import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.setMargins import com.futo.platformplayer.R @@ -31,13 +30,14 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.views.behavior.GestureControlView -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.PlaybackParameters -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout -import com.google.android.exoplayer2.ui.PlayerControlView -import com.google.android.exoplayer2.ui.StyledPlayerView -import com.google.android.exoplayer2.ui.TimeBar -import com.google.android.exoplayer2.video.VideoSize +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.VideoSize +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerControlView +import androidx.media3.ui.PlayerView +import androidx.media3.ui.TimeBar import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -58,7 +58,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase { //Views private val _root: ConstraintLayout; - private val _videoView: StyledPlayerView; + private val _videoView: PlayerView; val videoControls: PlayerControlView; private val _videoControls_fullscreen: PlayerControlView; @@ -127,6 +127,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase { val onVideoClicked = Event0(); val onTimeBarChanged = Event2(); + @OptIn(UnstableApi::class) constructor(context: Context, attrs: AttributeSet? = null) : super(PLAYER_STATE_NAME, context, attrs) { LayoutInflater.from(context).inflate(R.layout.video_view, this, true); _root = findViewById(R.id.videoview_root); @@ -139,8 +140,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _control_rotate_lock = videoControls.findViewById(R.id.exo_rotate_lock); _control_loop = videoControls.findViewById(R.id.exo_loop); _control_cast = videoControls.findViewById(R.id.exo_cast); - _control_play = videoControls.findViewById(com.google.android.exoplayer2.ui.R.id.exo_play); - _time_bar = videoControls.findViewById(com.google.android.exoplayer2.ui.R.id.exo_progress); + _control_play = videoControls.findViewById(androidx.media3.ui.R.id.exo_play); + _time_bar = videoControls.findViewById(androidx.media3.ui.R.id.exo_progress); _control_chapter = videoControls.findViewById(R.id.text_chapter_current); _buttonNext = videoControls.findViewById(R.id.button_next); _buttonPrevious = videoControls.findViewById(R.id.button_previous); @@ -152,9 +153,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _control_rotate_lock_fullscreen = _videoControls_fullscreen.findViewById(R.id.exo_rotate_lock); _control_loop_fullscreen = videoControls.findViewById(R.id.exo_loop); _control_cast_fullscreen = _videoControls_fullscreen.findViewById(R.id.exo_cast); - _control_play_fullscreen = videoControls.findViewById(com.google.android.exoplayer2.ui.R.id.exo_play); + _control_play_fullscreen = videoControls.findViewById(androidx.media3.ui.R.id.exo_play); _control_chapter_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_chapter_current); - _time_bar_fullscreen = _videoControls_fullscreen.findViewById(com.google.android.exoplayer2.ui.R.id.exo_progress); + _time_bar_fullscreen = _videoControls_fullscreen.findViewById(androidx.media3.ui.R.id.exo_progress); _buttonPrevious_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_previous); _buttonNext_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_next); @@ -404,14 +405,15 @@ class FutoVideoPlayer : FutoVideoPlayerBase { return false; } + @OptIn(UnstableApi::class) fun setArtwork(drawable: Drawable?) { if (drawable != null) { _videoView.defaultArtwork = drawable; - _videoView.artworkDisplayMode = StyledPlayerView.ARTWORK_DISPLAY_MODE_FILL; + _videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_FILL; fitOrFill(isFullScreen); } else { _videoView.defaultArtwork = null; - _videoView.artworkDisplayMode = StyledPlayerView.ARTWORK_DISPLAY_MODE_OFF; + _videoView.artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_OFF; } } @@ -436,6 +438,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase { return exoPlayer?.player?.playbackParameters?.speed ?: 1.0f; } + @OptIn(UnstableApi::class) fun setFullScreen(fullScreen: Boolean) { if (isFullScreen == fullScreen) { return; @@ -538,6 +541,7 @@ class FutoVideoPlayer : FutoVideoPlayerBase { } //Sizing + @OptIn(UnstableApi::class) fun fitHeight(videoSize : VideoSize? = null){ Logger.i(TAG, "Video Fit Height"); if(_originalBottomMargin != 0) { diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index 9a23b935..abe86c62 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt @@ -1,11 +1,27 @@ -@file:Suppress("DEPRECATION") - package com.futo.platformplayer.views.video import android.content.Context import android.net.Uri import android.util.AttributeSet import android.widget.RelativeLayout +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.VideoSize +import androidx.media3.common.text.CueGroup +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.dash.DashMediaSource +import androidx.media3.exoplayer.hls.HlsMediaSource +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.MergingMediaSource +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.exoplayer.source.SingleSampleMediaSource +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import com.futo.platformplayer.Settings import com.futo.platformplayer.api.media.models.chapters.IChapter import com.futo.platformplayer.api.media.models.streams.VideoMuxedSourceDescriptor @@ -28,22 +44,6 @@ import com.futo.platformplayer.helpers.VideoHelper import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.video.PlayerManager -import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.PlaybackException -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.source.MediaSource -import com.google.android.exoplayer2.source.MergingMediaSource -import com.google.android.exoplayer2.source.ProgressiveMediaSource -import com.google.android.exoplayer2.source.SingleSampleMediaSource -import com.google.android.exoplayer2.source.dash.DashMediaSource -import com.google.android.exoplayer2.source.hls.HlsMediaSource -import com.google.android.exoplayer2.text.CueGroup -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector -import com.google.android.exoplayer2.upstream.DefaultDataSource -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource -import com.google.android.exoplayer2.video.VideoSize import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -251,6 +251,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { _targetTrackAudioBitrate = bitrate; updateTrackSelector(); } + @OptIn(UnstableApi::class) private fun updateTrackSelector() { var builder = DefaultTrackSelector.Parameters.Builder(context); if(_targetTrackVideoHeight > 0) { @@ -298,6 +299,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { return loadSelectedSources(play, resume); } + @OptIn(UnstableApi::class) fun swapSubtitles(scope: CoroutineScope, subtitles: ISubtitleSource?) { if(subtitles == null) clearSubtitles(); @@ -369,6 +371,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { } //Video loads + @OptIn(UnstableApi::class) private fun swapVideoSourceLocal(videoSource: LocalVideoSource) { Logger.i(TAG, "Loading VideoSource [Local]"); val file = File(videoSource.filePath); @@ -377,14 +380,13 @@ abstract class FutoVideoPlayerBase : RelativeLayout { _lastVideoMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)) .createMediaSource(MediaItem.fromUri(Uri.fromFile(file))); } + @OptIn(UnstableApi::class) private fun swapVideoSourceUrlRange(videoSource: JSVideoUrlRangeSource) { Logger.i(TAG, "Loading JSVideoUrlRangeSource"); if(videoSource.hasItag) { //Temporary workaround for Youtube try { _lastVideoMediaSource = VideoHelper.convertItagSourceToChunkedDashSource(videoSource); - if(_lastVideoMediaSource == null) - throw java.lang.IllegalStateException("Dash manifest workaround failed"); return; } //If it fails to create the dash workaround, fallback to standard progressive @@ -397,18 +399,21 @@ abstract class FutoVideoPlayerBase : RelativeLayout { } else throw IllegalArgumentException("source without itag data..."); } + @OptIn(UnstableApi::class) private fun swapVideoSourceUrl(videoSource: IVideoUrlSource) { Logger.i(TAG, "Loading VideoSource [Url]"); _lastVideoMediaSource = ProgressiveMediaSource.Factory(DefaultHttpDataSource.Factory() .setUserAgent(DEFAULT_USER_AGENT)) .createMediaSource(MediaItem.fromUri(videoSource.getVideoUrl())); } + @OptIn(UnstableApi::class) private fun swapVideoSourceDash(videoSource: IDashManifestSource) { Logger.i(TAG, "Loading VideoSource [Dash]"); _lastVideoMediaSource = DashMediaSource.Factory(DefaultHttpDataSource.Factory() .setUserAgent(DEFAULT_USER_AGENT)) .createMediaSource(MediaItem.fromUri(videoSource.url)) } + @OptIn(UnstableApi::class) private fun swapVideoSourceHLS(videoSource: IHLSManifestSource) { Logger.i(TAG, "Loading VideoSource [HLS]"); _lastVideoMediaSource = HlsMediaSource.Factory(DefaultHttpDataSource.Factory() @@ -416,7 +421,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout { .createMediaSource(MediaItem.fromUri(videoSource.url)); } + //Audio loads + @OptIn(UnstableApi::class) private fun swapAudioSourceLocal(audioSource: LocalAudioSource) { Logger.i(TAG, "Loading AudioSource [Local]"); val file = File(audioSource.filePath); @@ -425,6 +432,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { _lastAudioMediaSource = ProgressiveMediaSource.Factory(DefaultDataSource.Factory(context)) .createMediaSource(MediaItem.fromUri(Uri.fromFile(file))); } + @OptIn(UnstableApi::class) private fun swapAudioSourceUrlRange(audioSource: JSAudioUrlRangeSource) { Logger.i(TAG, "Loading JSAudioUrlRangeSource"); if(audioSource.hasItag) { @@ -444,12 +452,14 @@ abstract class FutoVideoPlayerBase : RelativeLayout { } else throw IllegalArgumentException("source without itag data...") } + @OptIn(UnstableApi::class) private fun swapAudioSourceUrl(audioSource: IAudioUrlSource) { Logger.i(TAG, "Loading AudioSource [Url]"); _lastAudioMediaSource = ProgressiveMediaSource.Factory(DefaultHttpDataSource.Factory() .setUserAgent(DEFAULT_USER_AGENT)) .createMediaSource(MediaItem.fromUri(audioSource.getAudioUrl())); } + @OptIn(UnstableApi::class) private fun swapAudioSourceHLS(audioSource: IHLSManifestAudioSource) { Logger.i(TAG, "Loading AudioSource [HLS]"); _lastAudioMediaSource = HlsMediaSource.Factory(DefaultHttpDataSource.Factory() @@ -479,6 +489,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { return VideoHelper.selectBestAudioSource(video.video, PREFERED_AUDIO_CONTAINERS, preferredLanguage); } + @OptIn(UnstableApi::class) private fun loadSelectedSources(play: Boolean, resume: Boolean): Boolean { val sourceVideo = if(!isAudioMode || _lastAudioMediaSource == null) _lastVideoMediaSource else null; val sourceAudio = _lastAudioMediaSource; @@ -506,11 +517,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout { return true; } + @OptIn(UnstableApi::class) private fun reloadMediaSource(play: Boolean = false, resume: Boolean = true) { - val player = exoPlayer - if (player == null) - return; - + val player = exoPlayer ?: return val positionBefore = player.player.currentPosition; if(_mediaSource != null) { player.player.setMediaSource(_mediaSource!!); @@ -564,7 +573,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout { //PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> { _shouldPlaybackRestartOnConnectivity = true; - _connectivityLossTime_ms = System.currentTimeMillis() + if (playing) { + _connectivityLossTime_ms = System.currentTimeMillis() + } + Logger.i(TAG, "IO error, set _shouldPlaybackRestartOnConnectivity=true _connectivityLossTime_ms=$_connectivityLossTime_ms"); } } diff --git a/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java b/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java index 00a49cf8..d153c440 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java +++ b/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java @@ -1,26 +1,28 @@ package com.futo.platformplayer.views.video.datasources; -import static com.google.android.exoplayer2.upstream.HttpUtil.buildRangeRequestHeader; -import static com.google.android.exoplayer2.util.Assertions.checkNotNull; -import static com.google.android.exoplayer2.util.Util.castNonNull; +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Util.castNonNull; +import static androidx.media3.datasource.HttpUtil.buildRangeRequestHeader; import static java.lang.Math.min; import android.net.Uri; +import android.util.Log; + import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.PlaybackException; -import com.google.android.exoplayer2.upstream.BaseDataSource; -import com.google.android.exoplayer2.upstream.DataSourceException; -import com.google.android.exoplayer2.upstream.DataSpec; -import com.google.android.exoplayer2.upstream.DataSpec.HttpMethod; -import com.google.android.exoplayer2.upstream.HttpDataSource; -import com.google.android.exoplayer2.upstream.HttpUtil; -import com.google.android.exoplayer2.upstream.TransferListener; -import com.google.android.exoplayer2.util.Log; -import com.google.android.exoplayer2.util.Util; +import androidx.media3.common.C; +import androidx.media3.common.PlaybackException; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import androidx.media3.datasource.BaseDataSource; +import androidx.media3.datasource.DataSourceException; +import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.HttpDataSource; +import androidx.media3.datasource.HttpUtil; +import androidx.media3.datasource.TransferListener; + import com.google.common.base.Predicate; import com.google.common.collect.ForwardingMap; import com.google.common.collect.ImmutableMap; @@ -45,6 +47,7 @@ import java.util.zip.GZIPInputStream; * Based on the default ExoPlayer DefaultHttpDataSource */ +@UnstableApi public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { public static final class Factory implements HttpDataSource.Factory { @@ -142,7 +145,7 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { /** * Sets a content type {@link Predicate}. If a content type is rejected by the predicate then a * {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link - * JSHttpDataSource#open(com.google.android.exoplayer2.upstream.DataSpec)}. + * JSHttpDataSource#open(androidx.media3.datasource.DataSpec)}. * *

The default is {@code null}. * @@ -160,7 +163,7 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { * *

The default is {@code null}. * - *

See {@link com.google.android.exoplayer2.upstream.DataSource#addTransferListener(TransferListener)}. + *

See {@link androidx.media3.datasource.DataSource#addTransferListener(TransferListener)}. * * @param transferListener The listener that will be used. * @return This factory. @@ -367,12 +370,12 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { if (dataSpec.length != C.LENGTH_UNSET) { bytesToRead = dataSpec.length; } else { - long contentLength = - HttpUtil.getContentLength( - connection.getHeaderField(HttpHeaders.CONTENT_LENGTH), - connection.getHeaderField(HttpHeaders.CONTENT_RANGE)); - bytesToRead = - contentLength != C.LENGTH_UNSET ? (contentLength - bytesToSkip) : C.LENGTH_UNSET; + long contentLength = HttpUtil.getContentLength( + connection.getHeaderField(HttpHeaders.CONTENT_LENGTH), + connection.getHeaderField(HttpHeaders.CONTENT_RANGE) + ); + + bytesToRead = contentLength != C.LENGTH_UNSET ? (contentLength - bytesToSkip) : C.LENGTH_UNSET; } } else { // Gzip is enabled. If the server opts to use gzip then the content length in the response @@ -457,7 +460,7 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { /** Establishes a connection, following redirects to do so where permitted. */ private HttpURLConnection makeConnection(DataSpec dataSpec) throws IOException { URL url = new URL(dataSpec.uri.toString()); - @HttpMethod int httpMethod = dataSpec.httpMethod; + @DataSpec.HttpMethod int httpMethod = dataSpec.httpMethod; @Nullable byte[] httpBody = dataSpec.httpBody; long position = dataSpec.position; long length = dataSpec.length; @@ -543,7 +546,7 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource { */ private HttpURLConnection makeConnection( URL url, - @HttpMethod int httpMethod, + @DataSpec.HttpMethod int httpMethod, @Nullable byte[] httpBody, long position, long length, diff --git a/app/src/main/res/layout/fragview_video_detail.xml b/app/src/main/res/layout/fragview_video_detail.xml index 607de6ed..7652198e 100644 --- a/app/src/main/res/layout/fragview_video_detail.xml +++ b/app/src/main/res/layout/fragview_video_detail.xml @@ -39,7 +39,7 @@ android:elevation="4dp" android:layout_marginBottom="6dp" /> - - - - - - - - -