mirror of
https://github.com/recloudstream/cloudstream.git
synced 2024-08-15 01:53:11 +00:00
Add basic fcast support (#1084)
This commit is contained in:
parent
f1cc4db89c
commit
ee4d1dedc5
9 changed files with 329 additions and 3 deletions
|
@ -161,6 +161,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.requestRW
|
|||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
|
||||
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
|
||||
import com.lagradost.cloudstream3.utils.fcast.FcastManager
|
||||
import com.lagradost.nicehttp.Requests
|
||||
import com.lagradost.nicehttp.ResponseParser
|
||||
import com.lagradost.safefile.SafeFile
|
||||
|
@ -1756,6 +1757,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
|
|||
runAutoUpdate()
|
||||
}
|
||||
|
||||
FcastManager().init(this, false)
|
||||
|
||||
APIRepository.dubStatusActive = getApiDubstatusSettings()
|
||||
|
||||
try {
|
||||
|
|
|
@ -10,7 +10,8 @@ enum class LoadType {
|
|||
InAppDownload,
|
||||
ExternalApp,
|
||||
Browser,
|
||||
Chromecast
|
||||
Chromecast,
|
||||
Fcast
|
||||
}
|
||||
|
||||
fun LoadType.toSet() : Set<ExtractorLinkType> {
|
||||
|
@ -29,12 +30,17 @@ fun LoadType.toSet() : Set<ExtractorLinkType> {
|
|||
ExtractorLinkType.VIDEO,
|
||||
ExtractorLinkType.M3U8
|
||||
)
|
||||
LoadType.ExternalApp, LoadType.Unknown -> ExtractorLinkType.values().toSet()
|
||||
LoadType.ExternalApp, LoadType.Unknown -> ExtractorLinkType.entries.toSet()
|
||||
LoadType.Chromecast -> setOf(
|
||||
ExtractorLinkType.VIDEO,
|
||||
ExtractorLinkType.DASH,
|
||||
ExtractorLinkType.M3U8
|
||||
)
|
||||
LoadType.Fcast -> setOf(
|
||||
ExtractorLinkType.VIDEO,
|
||||
ExtractorLinkType.DASH,
|
||||
ExtractorLinkType.M3U8
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -55,6 +55,8 @@ const val ACTION_PLAY_EPISODE_IN_WEB_VIDEO = 16
|
|||
const val ACTION_PLAY_EPISODE_IN_MPV = 17
|
||||
|
||||
const val ACTION_MARK_AS_WATCHED = 18
|
||||
const val ACTION_FCAST = 19
|
||||
|
||||
const val TV_EP_SIZE_LARGE = 400
|
||||
const val TV_EP_SIZE_SMALL = 300
|
||||
data class EpisodeClickEvent(val action: Int, val data: ResultEpisode)
|
||||
|
|
|
@ -83,6 +83,10 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.setVideoWatchState
|
|||
import com.lagradost.cloudstream3.utils.DataStoreHelper.updateSubscribedData
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||
import com.lagradost.cloudstream3.utils.fcast.FcastManager
|
||||
import com.lagradost.cloudstream3.utils.fcast.FcastSession
|
||||
import com.lagradost.cloudstream3.utils.fcast.Opcode
|
||||
import com.lagradost.cloudstream3.utils.fcast.PlayMessage
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
@ -1519,6 +1523,13 @@ class ResultViewModel2 : ViewModel() {
|
|||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (FcastManager.currentDevices.isNotEmpty()) {
|
||||
options.add(
|
||||
txt(R.string.player_settings_play_in_fcast) to ACTION_FCAST
|
||||
)
|
||||
}
|
||||
|
||||
options.add(txt(R.string.episode_action_play_in_app) to ACTION_PLAY_EPISODE_IN_PLAYER)
|
||||
|
||||
for (app in apps) {
|
||||
|
@ -1694,6 +1705,39 @@ class ResultViewModel2 : ViewModel() {
|
|||
}
|
||||
}
|
||||
|
||||
ACTION_FCAST -> {
|
||||
val devices = FcastManager.currentDevices.toList()
|
||||
postPopup(
|
||||
txt(R.string.player_settings_select_cast_device),
|
||||
devices.map { txt(it.name) }) { index ->
|
||||
if (index == null) return@postPopup
|
||||
val device = devices.getOrNull(index)
|
||||
|
||||
acquireSingleLink(
|
||||
click.data,
|
||||
LoadType.Fcast,
|
||||
txt(R.string.episode_action_cast_mirror)
|
||||
) { (result, index) ->
|
||||
val host = device?.host ?: return@acquireSingleLink
|
||||
val link = result.links.firstOrNull() ?: return@acquireSingleLink
|
||||
|
||||
FcastSession(host).use { session ->
|
||||
session.sendMessage(
|
||||
Opcode.Play,
|
||||
PlayMessage(
|
||||
link.type.getMimeType(),
|
||||
link.url,
|
||||
headers = mapOf(
|
||||
"referer" to link.referer,
|
||||
"user-agent" to USER_AGENT
|
||||
) + link.headers
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ACTION_PLAY_EPISODE_IN_BROWSER -> acquireSingleLink(
|
||||
click.data,
|
||||
LoadType.Browser,
|
||||
|
|
|
@ -308,7 +308,18 @@ enum class ExtractorLinkType {
|
|||
/** No support at the moment */
|
||||
TORRENT,
|
||||
/** No support at the moment */
|
||||
MAGNET,
|
||||
MAGNET;
|
||||
|
||||
// See https://www.iana.org/assignments/media-types/media-types.xhtml
|
||||
fun getMimeType(): String {
|
||||
return when (this) {
|
||||
VIDEO -> "video/mp4"
|
||||
M3U8 -> "application/x-mpegURL"
|
||||
DASH -> "application/dash+xml"
|
||||
TORRENT -> "application/x-bittorrent"
|
||||
MAGNET -> "application/x-bittorrent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun inferTypeFromUrl(url: String): ExtractorLinkType {
|
||||
|
|
|
@ -0,0 +1,135 @@
|
|||
package com.lagradost.cloudstream3.utils.fcast
|
||||
|
||||
import android.content.Context
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdManager.ResolveListener
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
|
||||
class FcastManager {
|
||||
private var nsdManager: NsdManager? = null
|
||||
|
||||
// Used for receiver
|
||||
private val registrationListenerTcp = DefaultRegistrationListener()
|
||||
private fun getDeviceName(): String {
|
||||
return "${Build.MANUFACTURER}-${Build.MODEL}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the fcast service
|
||||
* @param registerReceiver If true will register the app as a compatible fcast receiver for discovery in other app
|
||||
*/
|
||||
fun init(context: Context, registerReceiver: Boolean) = ioSafe {
|
||||
nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||
val serviceType = "_fcast._tcp"
|
||||
|
||||
if (registerReceiver) {
|
||||
val serviceName = "$APP_PREFIX-${getDeviceName()}"
|
||||
|
||||
val serviceInfo = NsdServiceInfo().apply {
|
||||
this.serviceName = serviceName
|
||||
this.serviceType = serviceType
|
||||
this.port = TCP_PORT
|
||||
}
|
||||
|
||||
nsdManager?.registerService(
|
||||
serviceInfo,
|
||||
NsdManager.PROTOCOL_DNS_SD,
|
||||
registrationListenerTcp
|
||||
)
|
||||
}
|
||||
|
||||
nsdManager?.discoverServices(
|
||||
serviceType,
|
||||
NsdManager.PROTOCOL_DNS_SD,
|
||||
DefaultDiscoveryListener()
|
||||
)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
nsdManager?.unregisterService(registrationListenerTcp)
|
||||
}
|
||||
|
||||
inner class DefaultDiscoveryListener : NsdManager.DiscoveryListener {
|
||||
val tag = "DiscoveryListener"
|
||||
override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
|
||||
Log.d(tag, "Discovery failed: $serviceType, error code: $errorCode")
|
||||
}
|
||||
|
||||
override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) {
|
||||
Log.d(tag, "Stop discovery failed: $serviceType, error code: $errorCode")
|
||||
}
|
||||
|
||||
override fun onDiscoveryStarted(serviceType: String?) {
|
||||
Log.d(tag, "Discovery started: $serviceType")
|
||||
}
|
||||
|
||||
override fun onDiscoveryStopped(serviceType: String?) {
|
||||
Log.d(tag, "Discovery stopped: $serviceType")
|
||||
}
|
||||
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo?) {
|
||||
if (serviceInfo == null) return
|
||||
nsdManager?.resolveService(serviceInfo, object : ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {
|
||||
}
|
||||
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo?) {
|
||||
if (serviceInfo == null) return
|
||||
|
||||
currentDevices.add(PublicDeviceInfo(serviceInfo))
|
||||
|
||||
Log.d(
|
||||
tag,
|
||||
"Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}"
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo?) {
|
||||
if (serviceInfo == null) return
|
||||
|
||||
// May remove duplicates, but net and port is null here, preventing device specific identification
|
||||
currentDevices.removeAll {
|
||||
it.rawName == serviceInfo.serviceName
|
||||
}
|
||||
|
||||
Log.d(tag, "Service lost: ${serviceInfo.serviceName}")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val APP_PREFIX = "CloudStream"
|
||||
val currentDevices: MutableList<PublicDeviceInfo> = mutableListOf()
|
||||
|
||||
class DefaultRegistrationListener : NsdManager.RegistrationListener {
|
||||
val tag = "DiscoveryService"
|
||||
override fun onServiceRegistered(serviceInfo: NsdServiceInfo) {
|
||||
Log.d(tag, "Service registered: ${serviceInfo.serviceName}")
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
Log.e(tag, "Service registration failed: errorCode=$errorCode")
|
||||
}
|
||||
|
||||
override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) {
|
||||
Log.d(tag, "Service unregistered: ${serviceInfo.serviceName}")
|
||||
}
|
||||
|
||||
override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
Log.e(tag, "Service unregistration failed: errorCode=$errorCode")
|
||||
}
|
||||
}
|
||||
|
||||
const val TCP_PORT = 46899
|
||||
}
|
||||
}
|
||||
|
||||
class PublicDeviceInfo(serviceInfo: NsdServiceInfo) {
|
||||
val rawName: String = serviceInfo.serviceName
|
||||
val host: String? = serviceInfo.host.hostAddress
|
||||
val name = rawName.replace("-", " ") + host?.let { " $it" }
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package com.lagradost.cloudstream3.utils.fcast
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.toJson
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.safefile.closeQuietly
|
||||
import java.io.DataOutputStream
|
||||
import java.net.Socket
|
||||
import kotlin.jvm.Throws
|
||||
|
||||
class FcastSession(private val hostAddress: String): AutoCloseable {
|
||||
val tag = "FcastSession"
|
||||
|
||||
private var socket: Socket? = null
|
||||
@Throws
|
||||
@WorkerThread
|
||||
fun open(): Socket {
|
||||
val socket = Socket(hostAddress, FcastManager.TCP_PORT)
|
||||
this.socket = socket
|
||||
return socket
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
socket?.closeQuietly()
|
||||
socket = null
|
||||
}
|
||||
|
||||
@Throws
|
||||
private fun acquireSocket(): Socket {
|
||||
return socket ?: open()
|
||||
}
|
||||
|
||||
fun ping() {
|
||||
sendMessage(Opcode.Ping, null)
|
||||
}
|
||||
|
||||
fun <T> sendMessage(opcode: Opcode, message: T) {
|
||||
ioSafe {
|
||||
val socket = acquireSocket()
|
||||
val outputStream = DataOutputStream(socket.getOutputStream())
|
||||
|
||||
val json = message?.toJson()
|
||||
val content = json?.toByteArray() ?: ByteArray(0)
|
||||
|
||||
// Little endian starting from 1
|
||||
// https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1
|
||||
val size = content.size + 1
|
||||
|
||||
val sizeArray = ByteArray(4) { num ->
|
||||
(size shr 8 * num and 0xff).toByte()
|
||||
}
|
||||
|
||||
Log.d(tag, "Sending message with size: $size, opcode: $opcode")
|
||||
outputStream.write(sizeArray)
|
||||
outputStream.write(ByteArray(1) { opcode.value })
|
||||
outputStream.write(content)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
package com.lagradost.cloudstream3.utils.fcast
|
||||
|
||||
// See https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1
|
||||
enum class Opcode(val value: Byte) {
|
||||
None(0),
|
||||
Play(1),
|
||||
Pause(2),
|
||||
Resume(3),
|
||||
Stop(4),
|
||||
Seek(5),
|
||||
PlaybackUpdate(6),
|
||||
VolumeUpdate(7),
|
||||
SetVolume(8),
|
||||
PlaybackError(9),
|
||||
SetSpeed(10),
|
||||
Version(11),
|
||||
Ping(12),
|
||||
Pong(13);
|
||||
}
|
||||
|
||||
|
||||
data class PlayMessage(
|
||||
val container: String,
|
||||
val url: String? = null,
|
||||
val content: String? = null,
|
||||
val time: Double? = null,
|
||||
val speed: Double? = null,
|
||||
val headers: Map<String, String>? = null
|
||||
)
|
||||
|
||||
data class SeekMessage(
|
||||
val time: Double
|
||||
)
|
||||
|
||||
data class PlaybackUpdateMessage(
|
||||
val generationTime: Long,
|
||||
val time: Double,
|
||||
val duration: Double,
|
||||
val state: Int,
|
||||
val speed: Double
|
||||
)
|
||||
|
||||
data class VolumeUpdateMessage(
|
||||
val generationTime: Long,
|
||||
val volume: Double
|
||||
)
|
||||
|
||||
data class PlaybackErrorMessage(
|
||||
val message: String
|
||||
)
|
||||
|
||||
data class SetSpeedMessage(
|
||||
val speed: Double
|
||||
)
|
||||
|
||||
data class SetVolumeMessage(
|
||||
val volume: Double
|
||||
)
|
||||
|
||||
data class VersionMessage(
|
||||
val version: Long
|
||||
)
|
|
@ -357,6 +357,7 @@
|
|||
<string name="storage_error">Download error, check storage permissions</string>
|
||||
<string name="episode_action_chromecast_episode">Chromecast episode</string>
|
||||
<string name="episode_action_chromecast_mirror">Chromecast mirror</string>
|
||||
<string name="episode_action_cast_mirror">Cast mirror</string>
|
||||
<string name="episode_action_play_in_app">Play in app</string>
|
||||
<string name="episode_action_play_in_format">Play in %s</string>
|
||||
<string name="episode_action_play_in_browser">Play in browser</string>
|
||||
|
@ -634,7 +635,9 @@
|
|||
<string name="player_settings_play_in_vlc">VLC</string>
|
||||
<string name="player_settings_play_in_mpv">MPV</string>
|
||||
<string name="player_settings_play_in_web">Web Video Cast</string>
|
||||
<string name="player_settings_play_in_fcast">Fcast</string>
|
||||
<string name="player_settings_play_in_browser">Web browser</string>
|
||||
<string name="player_settings_select_cast_device">Select cast device</string>
|
||||
<string name="app_not_found_error">App not found</string>
|
||||
<string name="all_languages_preference">All Languages</string>
|
||||
<string name="skip_type_format" formatted="true">Skip %s</string>
|
||||
|
|
Loading…
Reference in a new issue