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.UIHelper.toPx
|
||||||
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
|
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
|
||||||
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_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.Requests
|
||||||
import com.lagradost.nicehttp.ResponseParser
|
import com.lagradost.nicehttp.ResponseParser
|
||||||
import com.lagradost.safefile.SafeFile
|
import com.lagradost.safefile.SafeFile
|
||||||
|
@ -1756,6 +1757,8 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener,
|
||||||
runAutoUpdate()
|
runAutoUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FcastManager().init(this, false)
|
||||||
|
|
||||||
APIRepository.dubStatusActive = getApiDubstatusSettings()
|
APIRepository.dubStatusActive = getApiDubstatusSettings()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -10,7 +10,8 @@ enum class LoadType {
|
||||||
InAppDownload,
|
InAppDownload,
|
||||||
ExternalApp,
|
ExternalApp,
|
||||||
Browser,
|
Browser,
|
||||||
Chromecast
|
Chromecast,
|
||||||
|
Fcast
|
||||||
}
|
}
|
||||||
|
|
||||||
fun LoadType.toSet() : Set<ExtractorLinkType> {
|
fun LoadType.toSet() : Set<ExtractorLinkType> {
|
||||||
|
@ -29,12 +30,17 @@ fun LoadType.toSet() : Set<ExtractorLinkType> {
|
||||||
ExtractorLinkType.VIDEO,
|
ExtractorLinkType.VIDEO,
|
||||||
ExtractorLinkType.M3U8
|
ExtractorLinkType.M3U8
|
||||||
)
|
)
|
||||||
LoadType.ExternalApp, LoadType.Unknown -> ExtractorLinkType.values().toSet()
|
LoadType.ExternalApp, LoadType.Unknown -> ExtractorLinkType.entries.toSet()
|
||||||
LoadType.Chromecast -> setOf(
|
LoadType.Chromecast -> setOf(
|
||||||
ExtractorLinkType.VIDEO,
|
ExtractorLinkType.VIDEO,
|
||||||
ExtractorLinkType.DASH,
|
ExtractorLinkType.DASH,
|
||||||
ExtractorLinkType.M3U8
|
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_PLAY_EPISODE_IN_MPV = 17
|
||||||
|
|
||||||
const val ACTION_MARK_AS_WATCHED = 18
|
const val ACTION_MARK_AS_WATCHED = 18
|
||||||
|
const val ACTION_FCAST = 19
|
||||||
|
|
||||||
const val TV_EP_SIZE_LARGE = 400
|
const val TV_EP_SIZE_LARGE = 400
|
||||||
const val TV_EP_SIZE_SMALL = 300
|
const val TV_EP_SIZE_SMALL = 300
|
||||||
data class EpisodeClickEvent(val action: Int, val data: ResultEpisode)
|
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.DataStoreHelper.updateSubscribedData
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper
|
import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
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 kotlinx.coroutines.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.TimeUnit
|
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)
|
options.add(txt(R.string.episode_action_play_in_app) to ACTION_PLAY_EPISODE_IN_PLAYER)
|
||||||
|
|
||||||
for (app in apps) {
|
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(
|
ACTION_PLAY_EPISODE_IN_BROWSER -> acquireSingleLink(
|
||||||
click.data,
|
click.data,
|
||||||
LoadType.Browser,
|
LoadType.Browser,
|
||||||
|
|
|
@ -308,7 +308,18 @@ enum class ExtractorLinkType {
|
||||||
/** No support at the moment */
|
/** No support at the moment */
|
||||||
TORRENT,
|
TORRENT,
|
||||||
/** No support at the moment */
|
/** 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 {
|
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="storage_error">Download error, check storage permissions</string>
|
||||||
<string name="episode_action_chromecast_episode">Chromecast episode</string>
|
<string name="episode_action_chromecast_episode">Chromecast episode</string>
|
||||||
<string name="episode_action_chromecast_mirror">Chromecast mirror</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_app">Play in app</string>
|
||||||
<string name="episode_action_play_in_format">Play in %s</string>
|
<string name="episode_action_play_in_format">Play in %s</string>
|
||||||
<string name="episode_action_play_in_browser">Play in browser</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_vlc">VLC</string>
|
||||||
<string name="player_settings_play_in_mpv">MPV</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_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_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="app_not_found_error">App not found</string>
|
||||||
<string name="all_languages_preference">All Languages</string>
|
<string name="all_languages_preference">All Languages</string>
|
||||||
<string name="skip_type_format" formatted="true">Skip %s</string>
|
<string name="skip_type_format" formatted="true">Skip %s</string>
|
||||||
|
|
Loading…
Reference in a new issue