Add basic fcast support (#1084)

This commit is contained in:
CranberrySoup 2024-05-09 19:46:54 +00:00 committed by GitHub
parent f1cc4db89c
commit ee4d1dedc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 329 additions and 3 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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