diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt index a07ae2c2..44d078e0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt @@ -94,6 +94,7 @@ import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.ui.player.BasicLink +import com.lagradost.cloudstream3.ui.player.ExtractorLinkGenerator import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.LinkGenerator import com.lagradost.cloudstream3.ui.result.LinearListLayout @@ -130,8 +131,11 @@ import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching import com.lagradost.cloudstream3.utils.Event +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.INFER_TYPE import com.lagradost.cloudstream3.utils.IOnBackPressed import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate +import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState import com.lagradost.cloudstream3.utils.UIHelper.checkWrite @@ -1112,16 +1116,17 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus -> // println("refocus $oldFocus -> $newFocus") try { - val r = Rect(0,0,0,0) + val r = Rect(0, 0, 0, 0) newFocus.getDrawingRect(r) val x = r.centerX() val y = r.centerY() val dx = 0 //screenWidth / 2 val dy = screenHeight / 2 - val r2 = Rect(x-dx,y-dy,x+dx,y+dy) + val r2 = Rect(x - dx, y - dy, x + dx, y + dy) newFocus.requestRectangleOnScreen(r2, false) - // TvFocus.current =TvFocus.current.copy(y=y.toFloat()) - } catch (_ : Throwable) { } + // TvFocus.current =TvFocus.current.copy(y=y.toFloat()) + } catch (_: Throwable) { + } TvFocus.updateFocusView(newFocus) /*var focus = newFocus @@ -1562,6 +1567,26 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener { setKey(HAS_DONE_SETUP_KEY, true) } + /*val defaultDirectory = "${filesDir.path}/torrent_tmp" + //File(defaultDirectory).deleteRecursively() + navigate( + R.id.global_to_navigation_player, GeneratorPlayer.newInstance( + ExtractorLinkGenerator( + listOf( + ExtractorLink( + source = "", + name = "hello world", + url = "", + "", + Qualities.Unknown.value, + type = INFER_TYPE + ) + ), + emptyList() + ) + ) + )*/ + // Used to check current focus for TV // main { // while (true) { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt index 8388e58f..efa03bd4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt @@ -370,12 +370,17 @@ abstract class AbstractPlayerFragment( // } //} + open fun onDownload(event : DownloadEvent) = Unit + /** This receives the events from the player, if you want to append functionality you do it here, * do note that this only receives events for UI changes, * and returning early WONT stop it from changing in eg the player time or pause status */ open fun mainCallback(event : PlayerEvent) { Log.i(TAG, "Handle event: $event") when(event) { + is DownloadEvent -> { + onDownload(event) + } is ResizedEvent -> { playerDimensionsLoaded(event.width, event.height) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index 331cfb73..b44ce003 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -1,6 +1,7 @@ package com.lagradost.cloudstream3.ui.player import android.annotation.SuppressLint +import android.app.Activity import android.content.Context import android.net.Uri import android.os.Handler @@ -8,6 +9,7 @@ import android.os.Looper import android.util.Log import android.util.Rational import android.widget.FrameLayout +import androidx.core.net.toUri import androidx.media3.common.C.* import androidx.media3.common.Format import androidx.media3.common.MediaItem @@ -49,6 +51,8 @@ import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.debugAssert @@ -63,6 +67,15 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.ExtractorUri import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTwoLettersToLanguage +import com.lagradost.fetchbutton.aria2c.Aria2Settings +import com.lagradost.fetchbutton.aria2c.Aria2Starter +import com.lagradost.fetchbutton.aria2c.DownloadListener +import com.lagradost.fetchbutton.aria2c.DownloadStatusTell +import com.lagradost.fetchbutton.aria2c.newUriRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.io.File import java.lang.IllegalArgumentException import java.util.UUID @@ -88,7 +101,9 @@ class CS3IPlayer : IPlayer { private var exoPlayer: ExoPlayer? = null set(value) { // If the old value is not null then the player has not been properly released. - debugAssert({ field != null && value != null }, { "Previous player instance should be released!" }) + debugAssert( + { field != null && value != null }, + { "Previous player instance should be released!" }) field = value } @@ -182,6 +197,273 @@ class CS3IPlayer : IPlayer { subtitleHelper.initSubtitles(subView, subHolder, style) } + private fun getPlayableFile( + data: com.lagradost.fetchbutton.aria2c.Metadata, + minimumBytes: Long + ): Uri? { + for (item in data.items) { + for (file in item.files) { + // only allow files with a length above minimumBytes + if (file.completedLength < minimumBytes) continue + // only allow video formats + if (videoFormats.none { suf -> + file.path.contains( + suf, + ignoreCase = true + ) + }) continue + + return file.path.toUri() + } + } + return null + } + + private val videoFormats = arrayOf( + ".3g2", + ".3gp", + ".amv", + ".asf", + ".avi", + ".drc", + ".flv", + ".f4v", + ".f4p", + ".f4a", + ".f4b", + ".gif", + ".gifv", + ".m4v", + ".mkv", + ".mng", + ".mov", + ".qt", + ".mp4", + ".m4p", + ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", + ".mpg", ".mpeg", ".m2v", + ".MTS", ".M2TS", ".TS", + ".mxf", + ".nsv", + ".ogv", ".ogg", + //".rm", // Made for RealPlayer + //".rmvb", // Made for RealPlayer + ".svi", + ".viv", + ".vob", + ".webm", + ".wmv", + ".yuv" + ) + + @Throws + private suspend fun awaitAria2c( + activity: Activity, + link: ExtractorLink, + requestId: Long, + ) { + var hasFileChecked = false + while (true) { + val gid = DownloadListener.sessionIdToGid[requestId] + + // request has not yet been processed, wait for it to do + if (gid == null) { + delay(1000) + continue + } + + val metadata = DownloadListener.getInfo(gid) + event( + DownloadEvent( + downloadedBytes = metadata.downloadedLength, + downloadSpeed = metadata.downloadSpeed, + totalBytes = metadata.totalLength, + connections = metadata.items.sumOf { it.connections } + ) + ) + when (metadata.status) { + // if completed/error/removed then we don't have to wait anymore + DownloadStatusTell.Complete, + DownloadStatusTell.Error, + DownloadStatusTell.Removed -> break + + // if waiting to be added, wait more + DownloadStatusTell.Waiting -> { + delay(1000) + continue + } + + DownloadStatusTell.Active -> { + if (metadata.downloadedLength >= metadata.totalLength && getPlayableFile( + metadata, + minimumBytes = 10 shl 20 + ) != null + ) break + + // as we don't want to waste the users time with torrents that is useless + // we do this to check that at a video file exists + if (!hasFileChecked && metadata.totalLength > (10 shl 20)) { + hasFileChecked = true + if (getPlayableFile( + metadata, + minimumBytes = -1 + ) == null + ) { + throw Exception("Download file has no video") + } + } + + println("downloaded ${metadata.downloadedLength}/${metadata.totalLength}") + delay(1000) + continue + } + + // if downloading then check if we have reached a stable file length + /*DownloadStatusTell.Active -> { + if (getPlayableFile(metadata, minimumBytes = 50 shl 20) != null) { + break + } + delay(1000) + continue + }*/ + + // unpause any pending files + DownloadStatusTell.Paused -> { + Aria2Starter.unpause(gid) + delay(1000) + continue + } + + null -> break + } + } + + val gid = DownloadListener.sessionIdToGid[requestId] + ?: throw Exception("Unable to start download") + + val metadata = DownloadListener.getInfo(gid) + + when (metadata.status) { + DownloadStatusTell.Active, DownloadStatusTell.Complete -> { + val uri = getPlayableFile(metadata, minimumBytes = 10 shl 20) + ?: throw Exception("Not downloaded enough") + activity.runOnUiThread { + //Log.i(TAG, "downloaded data: $metadata") + loadOfflinePlayer( + activity, + ExtractorUri( + // we require at least 10MB to play the file + uri = uri, + name = link.name, + tvType = TvType.Torrent + ) + ) + } + } + + DownloadStatusTell.Waiting -> { + throw Exception("Download was unable to be started") + } + + DownloadStatusTell.Paused -> { + throw Exception("Download is paused") + } + + DownloadStatusTell.Error -> { + throw Exception("Download error") + } + + DownloadStatusTell.Removed -> { + throw Exception("Download removed") + } + + null -> { + throw Exception("Unexpected download error") + } + } + } + + private fun releaseAria2c() = pauseAllAria2c() + private fun pauseAllAria2c() { + for ((_, gid) in DownloadListener.sessionIdToGid) { + Aria2Starter.pause(gid) + } + } + + + @Throws + private suspend fun playAria2c(activity: Activity, link: ExtractorLink) { + // ephemeral id based on url to make it unique + val requestId = link.url.hashCode().toLong() + + val uriReq = newUriRequest( + id = requestId, + uri = link.url, + fileName = null, + seed = false + ) + + val metadata = + DownloadListener.sessionIdToGid[requestId]?.let { gid -> DownloadListener.getInfo(gid) } + + when (metadata?.status) { + DownloadStatusTell.Removed, DownloadStatusTell.Error, null -> { + Aria2Starter.download(uriReq) + } + + else -> Unit + } + + try { + awaitAria2c(activity, link, requestId) + } catch (t: Throwable) { + // if we detect any download error then we delete it as we don't want any useless background tasks + Aria2Starter.delete(DownloadListener.sessionIdToGid[requestId], requestId) + throw t + } + } + + private fun loadAria2c(link: ExtractorLink) { + val act = CommonActivity.activity + if (act == null) { + event(ErrorEvent(IllegalArgumentException("No activity"))) + return + } + + CoroutineScope(Dispatchers.IO).launch { + try { + val defaultDirectory = "${act.cacheDir.path}/torrent_tmp" + + // start the client if not active, lazy init + if (Aria2Starter.client == null) { + Aria2Starter.start( + activity = act, + Aria2Settings( + UUID.randomUUID().toString(), + 4337, + defaultDirectory, + ) + ) + // remove all the cache + //File(defaultDirectory).deleteRecursively() + } + playAria2c(act, link) + } catch (t: Throwable) { + event(ErrorEvent(t)) + } + } + } + + /** hijacks the torrent links and downloads them with aria2c instead to be played */ + private fun loadOnlinePlayer(context: Context, link: ExtractorLink) { + when (link.type) { + ExtractorLinkType.TORRENT, ExtractorLinkType.MAGNET -> loadAria2c(link) + else -> { + loadOnlinePlayerReal(context, link) + } + } + } + override fun loadPlayer( context: Context, sameEpisode: Boolean, @@ -470,6 +752,7 @@ class CS3IPlayer : IPlayer { currentTextRenderer = null exoPlayer = null + releaseAria2c() //simpleCache = null } @@ -871,8 +1154,20 @@ class CS3IPlayer : IPlayer { CSPlayerEvent.SeekForward -> seekTime(seekActionTime, source) CSPlayerEvent.SeekBack -> seekTime(-seekActionTime, source) - CSPlayerEvent.NextEpisode -> event(EpisodeSeekEvent(offset = 1, source = source)) - CSPlayerEvent.PrevEpisode -> event(EpisodeSeekEvent(offset = -1, source = source)) + CSPlayerEvent.NextEpisode -> event( + EpisodeSeekEvent( + offset = 1, + source = source + ) + ) + + CSPlayerEvent.PrevEpisode -> event( + EpisodeSeekEvent( + offset = -1, + source = source + ) + ) + CSPlayerEvent.SkipCurrentChapter -> { //val dur = this@CS3IPlayer.getDuration() ?: return@apply getCurrentTimestamp()?.let { lastTimeStamp -> @@ -1249,7 +1544,7 @@ class CS3IPlayer : IPlayer { } @SuppressLint("UnsafeOptInUsageError") - private fun loadOnlinePlayer(context: Context, link: ExtractorLink) { + private fun loadOnlinePlayerReal(context: Context, link: ExtractorLink) { Log.i(TAG, "loadOnlinePlayer $link") try { currentLink = link diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt index b2542ffa..33d50fd3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt @@ -7,6 +7,7 @@ import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.os.Bundle +import android.text.format.Formatter import android.util.Log import android.view.LayoutInflater import android.view.View @@ -92,6 +93,52 @@ class GeneratorPlayer : FullScreenPlayer() { private var binding: FragmentPlayerBinding? = null + override fun playerDimensionsLoaded(width: Int, height: Int) { + setPlayerDimen(width to height) + showDownloadProgress(null) + } + + override fun playerError(exception: Throwable) { + Log.i(TAG, "playerError = $currentSelectedLink") + showDownloadProgress(null) + super.playerError(exception) + } + + override fun onDownload(event: DownloadEvent) { + super.onDownload(event) + showDownloadProgress(event) + } + + private fun showDownloadProgress(event: DownloadEvent?) { + activity?.runOnUiThread { + if(event == null) { + binding?.downloadHeader?.isVisible = false + return@runOnUiThread + } + binding?.downloadHeader?.isVisible = true + binding?.downloadedProgress?.apply { + val indeterminate = event.totalBytes <= 0 || event.downloadedBytes <= 0 + isIndeterminate = indeterminate + if (!indeterminate) { + max = (event.totalBytes / 1000).toInt() + progress = (event.downloadedBytes / 1000).toInt() + } + } + binding?.downloadedProgressText.setText( + txt( + R.string.download_size_format, + Formatter.formatShortFileSize(context, event.downloadedBytes), + Formatter.formatShortFileSize(context, event.totalBytes) + ) + ) + val downloadSpeed = Formatter.formatShortFileSize(context, event.downloadSpeed) + binding?.downloadedProgressSpeedText?.text = event.connections?.let { connections -> + "%s/s - %d Connections".format(downloadSpeed, connections) + } ?: downloadSpeed + } + } + + private fun startLoading() { player.release() currentSelectedSubtitles = null @@ -883,10 +930,6 @@ class GeneratorPlayer : FullScreenPlayer() { } - override fun playerError(exception: Throwable) { - Log.i(TAG, "playerError = $currentSelectedLink") - super.playerError(exception) - } private fun noLinksFound() { showToast(R.string.no_links_found_toast, Toast.LENGTH_SHORT) @@ -945,7 +988,7 @@ class GeneratorPlayer : FullScreenPlayer() { var maxEpisodeSet: Int? = null var hasRequestedStamps: Boolean = false - override fun playerPositionChanged(position: Long, duration : Long) { + override fun playerPositionChanged(position: Long, duration: Long) { // Don't save livestream data if ((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true) return @@ -1208,10 +1251,6 @@ class GeneratorPlayer : FullScreenPlayer() { } } - override fun playerDimensionsLoaded(width: Int, height : Int) { - setPlayerDimen(width to height) - } - private fun unwrapBundle(savedInstanceState: Bundle?) { Log.i(TAG, "unwrapBundle = $savedInstanceState") savedInstanceState?.let { bundle -> @@ -1358,6 +1397,11 @@ class GeneratorPlayer : FullScreenPlayer() { activity?.popCurrentPage() } + binding?.playerLoadingGoBack2?.setOnClickListener { + player.release() + activity?.popCurrentPage() + } + observe(viewModel.currentStamps) { stamps -> player.addTimeStamps(stamps) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt index af74cb57..007dec26 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt @@ -18,7 +18,10 @@ fun LoadType.toSet() : Set { LoadType.InApp -> setOf( ExtractorLinkType.VIDEO, ExtractorLinkType.DASH, - ExtractorLinkType.M3U8 + ExtractorLinkType.M3U8, + // testing + ExtractorLinkType.TORRENT, + ExtractorLinkType.MAGNET, ) LoadType.Browser -> setOf( ExtractorLinkType.VIDEO, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt index ec006234..ad722a27 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt @@ -72,9 +72,20 @@ data class PositionEvent( val durationMs: Long, ) : PlayerEvent() { /** how many ms (+-) we have skipped */ - val seekMs : Long get() = toMs - fromMs + val seekMs: Long get() = toMs - fromMs } +/** Used for torrent to pre-download a video before playing it */ +data class DownloadEvent( + val downloadedBytes: Long, + val totalBytes: Long, + /** bytes / sec */ + val downloadSpeed: Long, + val connections: Int?, + + override val source: PlayerEventSource = PlayerEventSource.Player +) : PlayerEvent() + /** player error when rendering or misc, used to display toast or log */ data class ErrorEvent( val error: Throwable, diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt index 5edff7a1..ce393c9d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/ExtractorApi.kt @@ -280,7 +280,7 @@ enum class ExtractorLinkType { private fun inferTypeFromUrl(url: String): ExtractorLinkType { val path = normalSafeApiCall { URL(url).path } return when { - path?.endsWith(".m3u8") == true -> ExtractorLinkType.M3U8 + path?.endsWith(".m3u8") == true || path?.endsWith(".m3u") == true -> ExtractorLinkType.M3U8 path?.endsWith(".mpd") == true -> ExtractorLinkType.DASH path?.endsWith(".torrent") == true -> ExtractorLinkType.TORRENT url.startsWith("magnet:") -> ExtractorLinkType.MAGNET diff --git a/app/src/main/res/layout/fragment_player.xml b/app/src/main/res/layout/fragment_player.xml index a620b6ae..93e3fc84 100644 --- a/app/src/main/res/layout/fragment_player.xml +++ b/app/src/main/res/layout/fragment_player.xml @@ -29,6 +29,78 @@ app:layout_constraintTop_toTopOf="parent" app:show_timeout="0" /> + + + + + + + + + + + + + + + + + + + + + + app:layout_constraintTop_toTopOf="parent" + tools:visibility="gone"> - + + --> \ No newline at end of file