Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay into article-web-support

This commit is contained in:
Kelvin 2025-05-29 13:05:09 +02:00
commit fb85aa4f32
21 changed files with 613 additions and 311 deletions

View file

@ -198,6 +198,7 @@ dependencies {
implementation 'com.google.android.flexbox:flexbox:3.0.0' implementation 'com.google.android.flexbox:flexbox:3.0.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation fileTree(dir: 'aar', include: ['*.aar']) implementation fileTree(dir: 'aar', include: ['*.aar'])
implementation 'com.arthenica:smart-exception-java:0.2.1'
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0' implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
implementation 'com.github.dhaval2404:imagepicker:2.1' implementation 'com.github.dhaval2404:imagepicker:2.1'
implementation 'com.google.zxing:core:3.4.1' implementation 'com.google.zxing:core:3.4.1'

View file

@ -56,7 +56,7 @@
<activity <activity
android:name=".activities.MainActivity" android:name=".activities.MainActivity"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:exported="true" android:exported="true"
android:theme="@style/Theme.FutoVideo.NoActionBar" android:theme="@style/Theme.FutoVideo.NoActionBar"
android:launchMode="singleInstance" android:launchMode="singleInstance"

View file

@ -4,8 +4,14 @@ import android.app.NotificationManager
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.activities.SettingsActivity
@ -37,6 +43,9 @@ import com.futo.platformplayer.models.Playlist
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.models.SubscriptionGroup import com.futo.platformplayer.models.SubscriptionGroup
import com.futo.platformplayer.parsers.HLS import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.parsers.HLS.MediaRendition
import com.futo.platformplayer.parsers.HLS.StreamInfo
import com.futo.platformplayer.parsers.HLS.VariantPlaylistReference
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateDownloads import com.futo.platformplayer.states.StateDownloads
import com.futo.platformplayer.states.StateHistory import com.futo.platformplayer.states.StateHistory
@ -63,6 +72,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.ByteArrayInputStream
import androidx.core.net.toUri
class UISlideOverlays { class UISlideOverlays {
companion object { companion object {
@ -299,6 +310,7 @@ class UISlideOverlays {
} }
@OptIn(UnstableApi::class)
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay { fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
val items = arrayListOf<View>(LoaderView(container.context)) val items = arrayListOf<View>(LoaderView(container.context))
val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items) val slideUpMenuOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.download_video), null, true, items)
@ -310,6 +322,8 @@ class UISlideOverlays {
val masterPlaylistContent = masterPlaylistResponse.body?.string() val masterPlaylistContent = masterPlaylistResponse.body?.string()
?: throw Exception("Master playlist content is empty") ?: throw Exception("Master playlist content is empty")
val resolvedPlaylistUrl = masterPlaylistResponse.url
val videoButtons = arrayListOf<SlideUpMenuItem>() val videoButtons = arrayListOf<SlideUpMenuItem>()
val audioButtons = arrayListOf<SlideUpMenuItem>() val audioButtons = arrayListOf<SlideUpMenuItem>()
//TODO: Implement subtitles //TODO: Implement subtitles
@ -322,7 +336,54 @@ class UISlideOverlays {
val masterPlaylist: HLS.MasterPlaylist val masterPlaylist: HLS.MasterPlaylist
try { try {
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl) val inputStream = ByteArrayInputStream(masterPlaylistContent.toByteArray())
val playlist = DefaultHlsPlaylistParserFactory().createPlaylistParser()
.parse(sourceUrl.toUri(), inputStream)
if (playlist is HlsMediaPlaylist) {
if (source is IHLSManifestAudioSource) {
val variant = HLS.mediaRenditionToVariant(MediaRendition("AUDIO", playlist.baseUri, "Single Playlist", null, null, null, null, null))!!
val estSize = VideoHelper.estimateSourceSize(variant);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
audioButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_music,
variant.name,
listOf(variant.language, variant.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
(prefix + variant.codec).trim(),
tag = variant,
call = {
selectedAudioVariant = variant
slideUpMenuOverlay.selectOption(audioButtons, variant)
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
},
invokeParent = false
))
} else {
val variant = HLS.variantReferenceToVariant(VariantPlaylistReference(playlist.baseUri, StreamInfo(null, null, null, null, null, null, null, null, null)))
val estSize = VideoHelper.estimateSourceSize(variant);
val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
videoButtons.add(SlideUpMenuItem(
container.context,
R.drawable.ic_movie,
variant.name,
"${variant.width}x${variant.height}",
(prefix + variant.codec).trim(),
tag = variant,
call = {
selectedVideoVariant = variant
slideUpMenuOverlay.selectOption(videoButtons, variant)
if (audioButtons.isEmpty()){
slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
}
},
invokeParent = false
))
}
} else if (playlist is HlsMultivariantPlaylist) {
masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, resolvedPlaylistUrl)
masterPlaylist.getAudioSources().forEach { it -> masterPlaylist.getAudioSources().forEach { it ->
@ -372,6 +433,7 @@ class UISlideOverlays {
invokeParent = false invokeParent = false
)) ))
} }
}
val newItems = arrayListOf<View>() val newItems = arrayListOf<View>()
if (videoButtons.isNotEmpty()) { if (videoButtons.isNotEmpty()) {
@ -398,11 +460,11 @@ class UISlideOverlays {
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) { if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (source is IHLSManifestSource) { if (source is IHLSManifestSource) {
StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, sourceUrl), null, null) StateDownloads.instance.download(video, HLSVariantVideoUrlSource("variant", 0, 0, "application/vnd.apple.mpegurl", "", null, 0, false, resolvedPlaylistUrl), null, null)
UIDialogs.toast(container.context, "Variant video HLS playlist download started") UIDialogs.toast(container.context, "Variant video HLS playlist download started")
slideUpMenuOverlay.hide() slideUpMenuOverlay.hide()
} else if (source is IHLSManifestAudioSource) { } else if (source is IHLSManifestAudioSource) {
StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, sourceUrl), null) StateDownloads.instance.download(video, null, HLSVariantAudioUrlSource("variant", 0, "application/vnd.apple.mpegurl", "", "", null, false, false, resolvedPlaylistUrl), null)
UIDialogs.toast(container.context, "Variant audio HLS playlist download started") UIDialogs.toast(container.context, "Variant audio HLS playlist download started")
slideUpMenuOverlay.hide() slideUpMenuOverlay.hide()
} else { } else {
@ -984,20 +1046,24 @@ class UISlideOverlays {
+ actions).filterNotNull() + actions).filterNotNull()
)); ));
items.add( items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto", SlideUpMenuGroup(
SlideUpMenuItem(container.context, container.context, container.context.getString(R.string.add_to), "addto",
SlideUpMenuItem(
container.context,
R.drawable.ic_queue_add, R.drawable.ic_queue_add,
container.context.getString(R.string.add_to_queue), container.context.getString(R.string.add_to_queue),
"${queue.size} " + container.context.getString(R.string.videos), "${queue.size} " + container.context.getString(R.string.videos),
tag = "queue", tag = "queue",
call = { StatePlayer.instance.addToQueue(video); }), call = { StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context, SlideUpMenuItem(
container.context,
R.drawable.ic_watchlist_add, R.drawable.ic_watchlist_add,
"${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "", "${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "",
"${watchLater.size} " + container.context.getString(R.string.videos), "${watchLater.size} " + container.context.getString(R.string.videos),
tag = "watch later", tag = "watch later",
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }), call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }),
SlideUpMenuItem(container.context, SlideUpMenuItem(
container.context,
R.drawable.ic_history, R.drawable.ic_history,
container.context.getString(R.string.add_to_history), container.context.getString(R.string.add_to_history),
"Mark as watched", "Mark as watched",
@ -1067,14 +1133,17 @@ class UISlideOverlays {
val queue = StatePlayer.instance.getQueue(); val queue = StatePlayer.instance.getQueue();
val watchLater = StatePlaylists.instance.getWatchLater(); val watchLater = StatePlaylists.instance.getWatchLater();
items.add( items.add(
SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other", SlideUpMenuGroup(
SlideUpMenuItem(container.context, container.context, container.context.getString(R.string.other), "other",
SlideUpMenuItem(
container.context,
R.drawable.ic_queue_add, R.drawable.ic_queue_add,
container.context.getString(R.string.queue), container.context.getString(R.string.queue),
"${queue.size} " + container.context.getString(R.string.videos), "${queue.size} " + container.context.getString(R.string.videos),
tag = "queue", tag = "queue",
call = { StatePlayer.instance.addToQueue(video); }), call = { StatePlayer.instance.addToQueue(video); }),
SlideUpMenuItem(container.context, SlideUpMenuItem(
container.context,
R.drawable.ic_watchlist_add, R.drawable.ic_watchlist_add,
StatePlayer.TYPE_WATCHLATER, StatePlayer.TYPE_WATCHLATER,
"${watchLater.size} " + container.context.getString(R.string.videos), "${watchLater.size} " + container.context.getString(R.string.videos),
@ -1121,8 +1190,8 @@ class UISlideOverlays {
return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() }; return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.add_to), null, true, items).apply { show() };
} }
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>, isChannelSearch: Boolean = false): SlideUpMenuFilters { fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters {
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues, isChannelSearch); val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues);
overlay.show(); overlay.show();
return overlay; return overlay;
} }

View file

@ -9,6 +9,7 @@ import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
@ -100,12 +101,18 @@ class SyncHomeActivity : AppCompatActivity() {
} }
} }
StateSync.instance.confirmStarted(this, { StateSync.instance.confirmStarted(this, onStarted = {
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity) if (StateSync.instance.syncService?.serverSocketFailedToStart == true) {
}, { UIDialogs.toast(this, "Server socket failed to start, is the port in use?", true)
}
if (StateSync.instance.syncService?.relayConnected == false) {
UIDialogs.toast(this, "Not connected to relay, remote connections will work.", false)
}
if (StateSync.instance.syncService?.serverSocketStarted == false) {
UIDialogs.toast(this, "Listener not started, local connections will not work.", false)
}
}, onNotStarted = {
finish() finish()
}, {
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
}) })
} }

View file

@ -536,7 +536,7 @@ class ChromecastCastingDevice : CastingDevice {
Logger.i(TAG, "Player not found, launching."); Logger.i(TAG, "Player not found, launching.");
launchPlayer(); launchPlayer();
} else { } else {
Logger.i(TAG, "Player not found, disconnecting."); Logger.i(TAG, "Player not found, disconnecting."); //TODO: Add recovery from this scenario ?
stop(); stop();
} }
} else { } else {

View file

@ -10,6 +10,8 @@ import android.os.Build
import android.os.Looper import android.os.Looper
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import java.net.NetworkInterface
import java.net.Inet4Address
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.futo.platformplayer.R import com.futo.platformplayer.R
@ -55,6 +57,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.net.Inet6Address
import java.net.InetAddress import java.net.InetAddress
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
@ -483,7 +486,7 @@ class StateCasting {
} }
} else { } else {
val proxyStreams = Settings.instance.casting.alwaysProxyRequests; val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
if (videoSource is IVideoUrlSource) { if (videoSource is IVideoUrlSource) {
@ -578,7 +581,7 @@ class StateCasting {
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val videoPath = "/video-${id}" val videoPath = "/video-${id}"
val videoUrl = url + videoPath; val videoUrl = url + videoPath;
@ -597,7 +600,7 @@ class StateCasting {
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> { private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val audioPath = "/audio-${id}" val audioPath = "/audio-${id}"
val audioUrl = url + audioPath; val audioUrl = url + audioPath;
@ -616,7 +619,7 @@ class StateCasting {
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> { private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> {
val ad = activeDevice ?: return listOf() val ad = activeDevice ?: return listOf()
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}" val url = getLocalUrl(ad)
val id = UUID.randomUUID() val id = UUID.randomUUID()
val hlsPath = "/hls-${id}" val hlsPath = "/hls-${id}"
@ -712,7 +715,7 @@ class StateCasting {
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> { private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val dashPath = "/dash-${id}" val dashPath = "/dash-${id}"
@ -762,7 +765,7 @@ class StateCasting {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val videoPath = "/video-${id}" val videoPath = "/video-${id}"
@ -827,7 +830,7 @@ class StateCasting {
_castServer.removeAllHandlers("castProxiedHlsMaster") _castServer.removeAllHandlers("castProxiedHlsMaster")
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val hlsPath = "/hls-${id}" val hlsPath = "/hls-${id}"
@ -997,7 +1000,7 @@ class StateCasting {
private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> { private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val hlsPath = "/hls-${id}" val hlsPath = "/hls-${id}"
@ -1127,7 +1130,7 @@ class StateCasting {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice; val proxyStreams = Settings.instance.casting.alwaysProxyRequests || ad !is FCastCastingDevice;
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val dashPath = "/dash-${id}" val dashPath = "/dash-${id}"
@ -1213,6 +1216,28 @@ class StateCasting {
} }
} }
private fun findFirstIPv4(): InetAddress? {
val interfaces = NetworkInterface.getNetworkInterfaces()
for (intf in interfaces) {
if (!intf.isUp || intf.isLoopback) continue
for (addr in intf.inetAddresses) {
if (addr is Inet4Address && !addr.isLoopbackAddress) {
return addr
}
}
}
return null
}
private fun getLocalUrl(ad: CastingDevice): String {
var address = ad.localAddress!!
if (address is Inet6Address && address.isLinkLocalAddress) {
address = findFirstIPv4() ?: address
Logger.i(TAG, "Selected casting address: $address")
}
return "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> { private suspend fun castDashRaw(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: JSDashManifestRawSource?, audioSource: JSDashManifestRawAudioSource?, subtitleSource: ISubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
@ -1220,7 +1245,7 @@ class StateCasting {
cleanExecutors() cleanExecutors()
_castServer.removeAllHandlers("castDashRaw") _castServer.removeAllHandlers("castDashRaw")
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"; val url = getLocalUrl(ad);
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val dashPath = "/dash-${id}" val dashPath = "/dash-${id}"

View file

@ -47,6 +47,7 @@ import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBitrate
import com.futo.platformplayer.toHumanBytesSpeed import com.futo.platformplayer.toHumanBytesSpeed
import com.futo.polycentric.core.hexStringToByteArray
import hasAnySource import hasAnySource
import isDownloadable import isDownloadable
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -59,16 +60,21 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Contextual import kotlinx.serialization.Contextual
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.lang.Thread.sleep import java.lang.Thread.sleep
import java.nio.ByteBuffer
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.UUID import java.util.UUID
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinPool
import java.util.concurrent.ForkJoinTask import java.util.concurrent.ForkJoinTask
import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadLocalRandom
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.time.times import kotlin.time.times
@ -564,6 +570,14 @@ class VideoDownload {
} }
} }
private fun decryptSegment(encryptedSegment: ByteArray, key: ByteArray, iv: ByteArray): ByteArray {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val secretKey = SecretKeySpec(key, "AES")
val ivSpec = IvParameterSpec(iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec)
return cipher.doFinal(encryptedSegment)
}
private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long { private suspend fun downloadHlsSource(context: Context, name: String, client: ManagedHttpClient, hlsUrl: String, targetFile: File, onProgress: (Long, Long, Long) -> Unit): Long {
if(targetFile.exists()) if(targetFile.exists())
targetFile.delete(); targetFile.delete();
@ -579,6 +593,14 @@ class VideoDownload {
?: throw Exception("Variant playlist content is empty") ?: throw Exception("Variant playlist content is empty")
val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl) val variantPlaylist = HLS.parseVariantPlaylist(vpContent, hlsUrl)
val decryptionInfo: DecryptionInfo? = if (variantPlaylist.decryptionInfo != null) {
val keyResponse = client.get(variantPlaylist.decryptionInfo.keyUrl)
check(keyResponse.isOk) { "HLS request failed for decryption key: ${keyResponse.code}" }
DecryptionInfo(keyResponse.body!!.bytes(), variantPlaylist.decryptionInfo.iv?.hexStringToByteArray())
} else {
null
}
variantPlaylist.segments.forEachIndexed { index, segment -> variantPlaylist.segments.forEachIndexed { index, segment ->
if (segment !is HLS.MediaSegment) { if (segment !is HLS.MediaSegment) {
return@forEachIndexed return@forEachIndexed
@ -590,7 +612,7 @@ class VideoDownload {
try { try {
segmentFiles.add(segmentFile) segmentFiles.add(segmentFile)
val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri) { segmentLength, totalRead, lastSpeed -> val segmentLength = downloadSource_Sequential(client, outputStream, segment.uri, if (index == 0) null else decryptionInfo, index) { segmentLength, totalRead, lastSpeed ->
val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index val averageSegmentLength = if (index == 0) segmentLength else downloadedTotalLength / index
val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength val expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed) onProgress(expectedTotalLength, downloadedTotalLength + totalRead, lastSpeed)
@ -630,12 +652,8 @@ class VideoDownload {
private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) { private suspend fun combineSegments(context: Context, segmentFiles: List<File>, targetFile: File) = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt") val cmd =
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.absolutePath}'" }) "-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
// 8 second analyze duration is needed for some Rumble HLS downloads
val cmd = "-analyzeduration 8M -f concat -safe 0 -i \"${fileList.absolutePath}\"" +
" -c copy \"${targetFile.absolutePath}\""
val statisticsCallback = StatisticsCallback { _ -> val statisticsCallback = StatisticsCallback { _ ->
//TODO: Show progress? //TODO: Show progress?
@ -645,7 +663,6 @@ class VideoDownload {
val session = FFmpegKit.executeAsync(cmd, val session = FFmpegKit.executeAsync(cmd,
{ session -> { session ->
if (ReturnCode.isSuccess(session.returnCode)) { if (ReturnCode.isSuccess(session.returnCode)) {
fileList.delete()
continuation.resumeWith(Result.success(Unit)) continuation.resumeWith(Result.success(Unit))
} else { } else {
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) { val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
@ -653,7 +670,6 @@ class VideoDownload {
} else { } else {
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}" "Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
} }
fileList.delete()
continuation.resumeWithException(RuntimeException(errorMessage)) continuation.resumeWithException(RuntimeException(errorMessage))
} }
}, },
@ -773,7 +789,7 @@ class VideoDownload {
else { else {
Logger.i(TAG, "Download $name Sequential"); Logger.i(TAG, "Download $name Sequential");
try { try {
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress); sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress);
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)") Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
throw e throw e
@ -800,7 +816,31 @@ class VideoDownload {
} }
return sourceLength!!; return sourceLength!!;
} }
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, onProgress: (Long, Long, Long) -> Unit): Long {
data class DecryptionInfo(
val key: ByteArray,
val iv: ByteArray?
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DecryptionInfo
if (!key.contentEquals(other.key)) return false
if (!iv.contentEquals(other.iv)) return false
return true
}
override fun hashCode(): Int {
var result = key.contentHashCode()
result = 31 * result + iv.contentHashCode()
return result
}
}
private fun downloadSource_Sequential(client: ManagedHttpClient, fileStream: FileOutputStream, url: String, decryptionInfo: DecryptionInfo?, index: Int, onProgress: (Long, Long, Long) -> Unit): Long {
val progressRate: Int = 4096 * 5; val progressRate: Int = 4096 * 5;
var lastProgressCount: Int = 0; var lastProgressCount: Int = 0;
val speedRate: Int = 4096 * 5; val speedRate: Int = 4096 * 5;
@ -820,6 +860,8 @@ class VideoDownload {
val sourceLength = result.body.contentLength(); val sourceLength = result.body.contentLength();
val sourceStream = result.body.byteStream(); val sourceStream = result.body.byteStream();
val segmentBuffer = ByteArrayOutputStream()
var totalRead: Long = 0; var totalRead: Long = 0;
try { try {
var read: Int; var read: Int;
@ -830,7 +872,7 @@ class VideoDownload {
if (read < 0) if (read < 0)
break; break;
fileStream.write(buffer, 0, read); segmentBuffer.write(buffer, 0, read);
totalRead += read; totalRead += read;
@ -856,6 +898,21 @@ class VideoDownload {
result.body.close() result.body.close()
} }
if (decryptionInfo != null) {
var iv = decryptionInfo.iv
if (iv == null) {
iv = ByteBuffer.allocate(16)
.putLong(0L)
.putLong(index.toLong())
.array()
}
val decryptedData = decryptSegment(segmentBuffer.toByteArray(), decryptionInfo.key, iv!!)
fileStream.write(decryptedData)
} else {
fileStream.write(segmentBuffer.toByteArray())
}
onProgress(sourceLength, totalRead, 0); onProgress(sourceLength, totalRead, 0);
return sourceLength; return sourceLength;
} }
@ -1162,6 +1219,8 @@ class VideoDownload {
fun audioContainerToExtension(container: String): String { fun audioContainerToExtension(container: String): String {
if (container.contains("audio/mp4")) if (container.contains("audio/mp4"))
return "mp4a"; return "mp4a";
else if (container.contains("video/mp4"))
return "mp4";
else if (container.contains("audio/mpeg")) else if (container.contains("audio/mpeg"))
return "mpga"; return "mpga";
else if (container.contains("audio/mp3")) else if (container.contains("audio/mp3"))
@ -1169,7 +1228,7 @@ class VideoDownload {
else if (container.contains("audio/webm")) else if (container.contains("audio/webm"))
return "webm"; return "webm";
else if (container == "application/vnd.apple.mpegurl") else if (container == "application/vnd.apple.mpegurl")
return "mp4a"; return "mp4";
else else
return "audio";// throw IllegalStateException("Unknown container: " + container) return "audio";// throw IllegalStateException("Unknown container: " + container)
} }

View file

@ -69,7 +69,7 @@ class VideoExport {
outputFile = f; outputFile = f;
} else if (v != null) { } else if (v != null) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container); val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.videoContainerToExtension(v.container);
val f = downloadRoot.createFile(v.container, outputFileName) val f = downloadRoot.createFile(if (v.container == "application/vnd.apple.mpegurl") "video/mp4" else v.container, outputFileName)
?: throw Exception("Failed to create file in external directory."); ?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying video."); Logger.i(TAG, "Copying video.");
@ -81,7 +81,7 @@ class VideoExport {
outputFile = f; outputFile = f;
} else if (a != null) { } else if (a != null) {
val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container); val outputFileName = videoLocal.name.sanitizeFileName(true) + "." + VideoDownload.audioContainerToExtension(a.container);
val f = downloadRoot.createFile(a.container, outputFileName) val f = downloadRoot.createFile(if (a.container == "application/vnd.apple.mpegurl") "video/mp4" else a.container, outputFileName)
?: throw Exception("Failed to create file in external directory."); ?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying audio."); Logger.i(TAG, "Copying audio.");

View file

@ -5,6 +5,7 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
@ -25,6 +26,7 @@ import com.futo.platformplayer.api.media.structures.MultiPager
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.Event2 import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp
import com.futo.platformplayer.engine.exceptions.PluginException import com.futo.platformplayer.engine.exceptions.PluginException
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
import com.futo.platformplayer.exceptions.ChannelException import com.futo.platformplayer.exceptions.ChannelException
@ -32,9 +34,11 @@ import com.futo.platformplayer.fragment.mainactivity.main.FeedView
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StateCache
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StatePlugins
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.SearchView
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
@ -54,6 +58,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
private var _results: ArrayList<IPlatformContent> = arrayListOf(); private var _results: ArrayList<IPlatformContent> = arrayListOf();
private var _adapterResults: InsertedViewAdapterWithLoader<ContentPreviewViewHolder>? = null; private var _adapterResults: InsertedViewAdapterWithLoader<ContentPreviewViewHolder>? = null;
private var _lastPolycentricProfile: PolycentricProfile? = null; private var _lastPolycentricProfile: PolycentricProfile? = null;
private var _query: String? = null
private var _searchView: SearchView? = null
val onContentClicked = Event2<IPlatformContent, Long>(); val onContentClicked = Event2<IPlatformContent, Long>();
val onContentUrlClicked = Event2<String, ContentType>(); val onContentUrlClicked = Event2<String, ContentType>();
@ -68,17 +74,32 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> { private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
Logger.i(TAG, "getContentPager"); Logger.i(TAG, "getContentPager");
var pager: IPager<IPlatformContent>? = null
val query = _query
if (!query.isNullOrBlank()) {
if(subType != null) {
Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query}, subType = ${subType})")
pager = StatePlatform.instance.searchChannel(channel.url, query, subType);
} else {
Logger.i(TAG, "StatePlatform.instance.searchChannel(channel.url = ${channel.url}, query = ${query})")
pager = StatePlatform.instance.searchChannel(channel.url, query);
}
} else {
val lastPolycentricProfile = _lastPolycentricProfile; val lastPolycentricProfile = _lastPolycentricProfile;
var pager: IPager<IPlatformContent>? = null; if (lastPolycentricProfile != null && StatePolycentric.instance.enabled) {
if (lastPolycentricProfile != null && StatePolycentric.instance.enabled) pager = StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType);
pager = Logger.i(TAG, "StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = ${subType})")
StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType); }
if(pager == null) { if(pager == null) {
if(subType != null) if(subType != null) {
pager = StatePlatform.instance.getChannelContent(channel.url, subType); pager = StatePlatform.instance.getChannelContent(channel.url, subType);
else Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url}, subType = ${subType})")
} else {
pager = StatePlatform.instance.getChannelContent(channel.url); pager = StatePlatform.instance.getChannelContent(channel.url);
Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url})")
}
}
} }
return pager; return pager;
} }
@ -145,19 +166,49 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
_taskLoadVideos.cancel(); _taskLoadVideos.cancel();
_query = null
_channel = channel; _channel = channel;
updateSearchViewVisibility()
_results.clear(); _results.clear();
_adapterResults?.notifyDataSetChanged(); _adapterResults?.notifyDataSetChanged();
loadInitial(); loadInitial();
} }
private fun updateSearchViewVisibility() {
if (subType != null) {
_searchView?.visibility = View.GONE
return
}
val client = _channel?.id?.pluginId?.let { StatePlatform.instance.getClientOrNull(it) }
Logger.i(TAG, "_searchView.visible = ${client?.capabilities?.hasSearchChannelContents == true}")
_searchView?.visibility = if (client?.capabilities?.hasSearchChannelContents == true) View.VISIBLE else View.GONE
}
fun setQuery(query: String) {
_query = query
_taskLoadVideos.cancel()
_results.clear()
_adapterResults?.notifyDataSetChanged()
loadInitial()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_channel_videos, container, false); val view = inflater.inflate(R.layout.fragment_channel_videos, container, false);
_query = null
_recyclerResults = view.findViewById(R.id.recycler_videos); _recyclerResults = view.findViewById(R.id.recycler_videos);
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar).apply { val searchView = SearchView(requireContext()).apply { layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT) }.apply {
onEnter.subscribe {
setQuery(it)
}
}
_searchView = searchView
updateSearchViewVisibility()
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar, viewsToPrepend = arrayListOf(searchView)).apply {
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit); this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit); this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit); this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
@ -174,6 +225,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
_recyclerResults?.layoutManager = _glmVideo; _recyclerResults?.layoutManager = _glmVideo;
_recyclerResults?.addOnScrollListener(_scrollListener); _recyclerResults?.addOnScrollListener(_scrollListener);
return view; return view;
} }
@ -182,6 +234,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
_recyclerResults?.removeOnScrollListener(_scrollListener); _recyclerResults?.removeOnScrollListener(_scrollListener);
_recyclerResults = null; _recyclerResults = null;
_pager = null; _pager = null;
_query = null
_searchView = null
_taskLoadVideos.cancel(); _taskLoadVideos.cancel();
_nextPageHandler.cancel(); _nextPageHandler.cancel();
@ -304,6 +358,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
} }
private fun loadInitial() { private fun loadInitial() {
Logger.i(TAG, "loadInitial")
val channel: IPlatformChannel = _channel ?: return; val channel: IPlatformChannel = _channel ?: return;
setLoading(true); setLoading(true);
_taskLoadVideos.run(channel); _taskLoadVideos.run(channel);

View file

@ -425,17 +425,15 @@ class ChannelFragment : MainFragment() {
_fragment.lifecycleScope.launch(Dispatchers.IO) { _fragment.lifecycleScope.launch(Dispatchers.IO) {
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url) val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
buttons.add(Pair(R.drawable.ic_search) { buttons.add(Pair(R.drawable.ic_search) {
_fragment.navigate<SuggestionsFragment>( _fragment.navigate<SuggestionsFragment>(
SuggestionsFragmentData( SuggestionsFragmentData(
"", SearchType.VIDEO, channel.url "", SearchType.VIDEO
) )
) )
}) })
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons) _fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
}
if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) { if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) {
if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false && if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false &&
!(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) { !(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) {

View file

@ -89,7 +89,6 @@ class ContentSearchResultsFragment : MainFragment() {
private var _sortBy: String? = null; private var _sortBy: String? = null;
private var _filterValues: HashMap<String, List<String>> = hashMapOf(); private var _filterValues: HashMap<String, List<String>> = hashMapOf();
private var _enabledClientIds: List<String>? = null; private var _enabledClientIds: List<String>? = null;
private var _channelUrl: String? = null;
private var _searchType: SearchType? = null; private var _searchType: SearchType? = null;
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>; private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
@ -98,10 +97,6 @@ class ContentSearchResultsFragment : MainFragment() {
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) { constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query -> _taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
Logger.i(TAG, "Searching for: $query") Logger.i(TAG, "Searching for: $query")
val channelUrl = _channelUrl;
if (channelUrl != null) {
StatePlatform.instance.searchChannel(channelUrl, query, null, _sortBy, _filterValues, _enabledClientIds)
} else {
when (_searchType) when (_searchType)
{ {
SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds) SearchType.VIDEO -> StatePlatform.instance.searchRefresh(fragment.lifecycleScope, query, null, _sortBy, _filterValues, _enabledClientIds)
@ -109,7 +104,6 @@ class ContentSearchResultsFragment : MainFragment() {
SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query) SearchType.PLAYLIST -> StatePlatform.instance.searchPlaylist(query)
else -> throw Exception("Search type must be specified") else -> throw Exception("Search type must be specified")
} }
}
}) })
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { } .success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> { .exception<Throwable> {
@ -147,7 +141,6 @@ class ContentSearchResultsFragment : MainFragment() {
fun onShown(parameter: Any?) { fun onShown(parameter: Any?) {
if(parameter is SuggestionsFragmentData) { if(parameter is SuggestionsFragmentData) {
setQuery(parameter.query, false); setQuery(parameter.query, false);
setChannelUrl(parameter.channelUrl, false);
setSearchType(parameter.searchType, false) setSearchType(parameter.searchType, false)
fragment.topBar?.apply { fragment.topBar?.apply {
@ -164,7 +157,7 @@ class ContentSearchResultsFragment : MainFragment() {
onFilterClick.subscribe(this) { onFilterClick.subscribe(this) {
_overlayContainer.let { _overlayContainer.let {
val filterValuesCopy = HashMap(_filterValues); val filterValuesCopy = HashMap(_filterValues);
val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy, _channelUrl != null); val filtersOverlay = UISlideOverlays.showFiltersOverlay(lifecycleScope, it, _enabledClientIds!!, filterValuesCopy);
filtersOverlay.onOK.subscribe { enabledClientIds, changed -> filtersOverlay.onOK.subscribe { enabledClientIds, changed ->
if (changed) { if (changed) {
setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy); setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy);
@ -211,11 +204,7 @@ class ContentSearchResultsFragment : MainFragment() {
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val commonCapabilities = val commonCapabilities = StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
if(_channelUrl == null)
StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
else
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
val sorts = commonCapabilities?.sorts ?: listOf(); val sorts = commonCapabilities?.sorts ?: listOf();
if (sorts.size > 1) { if (sorts.size > 1) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -282,15 +271,6 @@ class ContentSearchResultsFragment : MainFragment() {
} }
} }
private fun setChannelUrl(channelUrl: String?, updateResults: Boolean = true) {
_channelUrl = channelUrl;
if (updateResults) {
clearResults();
loadResults();
}
}
private fun setSearchType(searchType: SearchType, updateResults: Boolean = true) { private fun setSearchType(searchType: SearchType, updateResults: Boolean = true) {
_searchType = searchType _searchType = searchType

View file

@ -21,7 +21,7 @@ import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter
import com.futo.platformplayer.views.others.RadioGroupView import com.futo.platformplayer.views.others.RadioGroupView
import com.futo.platformplayer.views.others.TagsView import com.futo.platformplayer.views.others.TagsView
data class SuggestionsFragmentData(val query: String, val searchType: SearchType, val channelUrl: String? = null); data class SuggestionsFragmentData(val query: String, val searchType: SearchType);
class SuggestionsFragment : MainFragment { class SuggestionsFragment : MainFragment {
override val isMainView : Boolean = true; override val isMainView : Boolean = true;
@ -34,7 +34,6 @@ class SuggestionsFragment : MainFragment {
private val _suggestions: ArrayList<String> = ArrayList(); private val _suggestions: ArrayList<String> = ArrayList();
private var _query: String? = null; private var _query: String? = null;
private var _searchType: SearchType = SearchType.VIDEO; private var _searchType: SearchType = SearchType.VIDEO;
private var _channelUrl: String? = null;
private val _adapterSuggestions = SearchSuggestionAdapter(_suggestions); private val _adapterSuggestions = SearchSuggestionAdapter(_suggestions);
@ -52,7 +51,7 @@ class SuggestionsFragment : MainFragment {
_adapterSuggestions.onClicked.subscribe { suggestion -> _adapterSuggestions.onClicked.subscribe { suggestion ->
val storage = FragmentedStorage.get<SearchHistoryStorage>(); val storage = FragmentedStorage.get<SearchHistoryStorage>();
storage.add(suggestion); storage.add(suggestion);
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType, _channelUrl)); navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType));
} }
_adapterSuggestions.onRemove.subscribe { suggestion -> _adapterSuggestions.onRemove.subscribe { suggestion ->
val index = _suggestions.indexOf(suggestion); val index = _suggestions.indexOf(suggestion);
@ -109,10 +108,8 @@ class SuggestionsFragment : MainFragment {
if (parameter is SuggestionsFragmentData) { if (parameter is SuggestionsFragmentData) {
_searchType = parameter.searchType; _searchType = parameter.searchType;
_channelUrl = parameter.channelUrl;
} else if (parameter is SearchType) { } else if (parameter is SearchType) {
_searchType = parameter; _searchType = parameter;
_channelUrl = null;
} }
_radioGroupView?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true) _radioGroupView?.setOptions(listOf(Pair("Media", SearchType.VIDEO), Pair("Creators", SearchType.CREATOR), Pair("Playlists", SearchType.PLAYLIST)), listOf(_searchType), false, true)
@ -135,7 +132,7 @@ class SuggestionsFragment : MainFragment {
} }
} }
else else
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType, _channelUrl)); navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType));
}; };
onTextChange.subscribe(this) { onTextChange.subscribe(this) {

View file

@ -2680,9 +2680,10 @@ class VideoDetailView : ConstraintLayout {
} }
onChannelClicked.subscribe { onChannelClicked.subscribe {
if(it.url.isNotBlank()) if(it.url.isNotBlank()) {
fragment.minimizeVideoDetail()
fragment.navigate<ChannelFragment>(it) fragment.navigate<ChannelFragment>(it)
else } else
UIDialogs.appToast("No author url present"); UIDialogs.appToast("No author url present");
} }

View file

@ -88,7 +88,6 @@ class SearchTopBarFragment : TopFragment() {
} else if (parameter is SuggestionsFragmentData) { } else if (parameter is SuggestionsFragmentData) {
this.setText(parameter.query); this.setText(parameter.query);
_searchType = parameter.searchType; _searchType = parameter.searchType;
_channelUrl = parameter.channelUrl;
} }
if(currentMain is SuggestionsFragment) if(currentMain is SuggestionsFragment)
@ -114,7 +113,7 @@ class SearchTopBarFragment : TopFragment() {
fun clear() { fun clear() {
_editSearch?.text?.clear(); _editSearch?.text?.clear();
if (currentMain !is SuggestionsFragment) { if (currentMain !is SuggestionsFragment) {
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType, _channelUrl), false); navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType), false);
} else { } else {
onSearch.emit(""); onSearch.emit("");
} }

View file

@ -1,5 +1,11 @@
package com.futo.platformplayer.parsers package com.futo.platformplayer.parsers
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantSubtitleUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.HLSVariantVideoUrlSource
@ -7,12 +13,15 @@ import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudi
import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestSource
import com.futo.platformplayer.toYesNo import com.futo.platformplayer.toYesNo
import com.futo.platformplayer.yesNoToBoolean import com.futo.platformplayer.yesNoToBoolean
import java.io.ByteArrayInputStream
import java.net.URI import java.net.URI
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.text.ifEmpty
class HLS { class HLS {
companion object { companion object {
@OptIn(UnstableApi::class)
fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist { fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist {
val baseUrl = URI(sourceUrl).resolve("./").toString() val baseUrl = URI(sourceUrl).resolve("./").toString()
@ -49,6 +58,31 @@ class HLS {
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments) return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
} }
fun mediaRenditionToVariant(rendition: MediaRendition): HLSVariantAudioUrlSource? {
if (rendition.uri == null) {
return null
}
val suffix = listOf(rendition.language, rendition.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return when (rendition.type) {
"AUDIO" -> HLSVariantAudioUrlSource(rendition.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", rendition.language ?: "", null, false, false, rendition.uri)
else -> null
}
}
fun variantReferenceToVariant(reference: VariantPlaylistReference): HLSVariantVideoUrlSource {
var width: Int? = null
var height: Int? = null
val resolutionTokens = reference.streamInfo.resolution?.split('x')
if (resolutionTokens?.isNotEmpty() == true) {
width = resolutionTokens[0].toIntOrNull()
height = resolutionTokens[1].toIntOrNull()
}
val suffix = listOf(reference.streamInfo.video, reference.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", reference.streamInfo.codecs ?: "", reference.streamInfo.bandwidth, 0, false, reference.url)
}
fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist { fun parseVariantPlaylist(content: String, sourceUrl: String): VariantPlaylist {
val lines = content.lines() val lines = content.lines()
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull()
@ -61,7 +95,25 @@ class HLS {
val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":") val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) } val streamInfo = lines.find { it.startsWith("#EXT-X-STREAM-INF:") }?.let { parseStreamInfo(it) }
val keyInfo =
lines.find { it.startsWith("#EXT-X-KEY:") }?.substringAfter(":")?.split(",")
val key = keyInfo?.find { it.startsWith("URI=") }?.substringAfter("=")?.trim('"')
val iv =
keyInfo?.find { it.startsWith("IV=") }?.substringAfter("=")?.substringAfter("x")
val decryptionInfo: DecryptionInfo? = key?.let { k ->
DecryptionInfo(k, iv)
}
val initSegment =
lines.find { it.startsWith("#EXT-X-MAP:") }?.substringAfter(":")?.split(",")?.get(0)
?.substringAfter("=")?.trim('"')
val segments = mutableListOf<Segment>() val segments = mutableListOf<Segment>()
if (initSegment != null) {
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
}
var currentSegment: MediaSegment? = null var currentSegment: MediaSegment? = null
lines.forEach { line -> lines.forEach { line ->
when { when {
@ -86,7 +138,7 @@ class HLS {
} }
} }
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments) return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, streamInfo, segments, decryptionInfo)
} }
fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> { fun parseAndGetVideoSources(source: Any, content: String, url: String): List<HLSVariantVideoUrlSource> {
@ -270,7 +322,7 @@ class HLS {
val name: String?, val name: String?,
val isDefault: Boolean?, val isDefault: Boolean?,
val isAutoSelect: Boolean?, val isAutoSelect: Boolean?,
val isForced: Boolean? val isForced: Boolean?,
) { ) {
fun toM3U8Line(): String = buildString { fun toM3U8Line(): String = buildString {
append("#EXT-X-MEDIA:") append("#EXT-X-MEDIA:")
@ -319,30 +371,13 @@ class HLS {
fun getVideoSources(): List<HLSVariantVideoUrlSource> { fun getVideoSources(): List<HLSVariantVideoUrlSource> {
return variantPlaylistsRefs.map { return variantPlaylistsRefs.map {
var width: Int? = null variantReferenceToVariant(it)
var height: Int? = null
val resolutionTokens = it.streamInfo.resolution?.split('x')
if (resolutionTokens?.isNotEmpty() == true) {
width = resolutionTokens[0].toIntOrNull()
height = resolutionTokens[1].toIntOrNull()
}
val suffix = listOf(it.streamInfo.video, it.streamInfo.codecs).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
HLSVariantVideoUrlSource(suffix, width ?: 0, height ?: 0, "application/vnd.apple.mpegurl", it.streamInfo.codecs ?: "", it.streamInfo.bandwidth, 0, false, it.url)
} }
} }
fun getAudioSources(): List<HLSVariantAudioUrlSource> { fun getAudioSources(): List<HLSVariantAudioUrlSource> {
return mediaRenditions.mapNotNull { return mediaRenditions.mapNotNull {
if (it.uri == null) { return@mapNotNull mediaRenditionToVariant(it)
return@mapNotNull null
}
val suffix = listOf(it.language, it.groupID).mapNotNull { x -> x?.ifEmpty { null } }.joinToString(", ")
return@mapNotNull when (it.type) {
"AUDIO" -> HLSVariantAudioUrlSource(it.name?.ifEmpty { "Audio (${suffix})" } ?: "Audio (${suffix})", 0, "application/vnd.apple.mpegurl", "", it.language ?: "", null, false, false, it.uri)
else -> null
}
} }
} }
@ -368,6 +403,11 @@ class HLS {
} }
} }
data class DecryptionInfo(
val keyUrl: String,
val iv: String?
)
data class VariantPlaylist( data class VariantPlaylist(
val version: Int?, val version: Int?,
val targetDuration: Int?, val targetDuration: Int?,
@ -376,7 +416,8 @@ class HLS {
val programDateTime: ZonedDateTime?, val programDateTime: ZonedDateTime?,
val playlistType: String?, val playlistType: String?,
val streamInfo: StreamInfo?, val streamInfo: StreamInfo?,
val segments: List<Segment> val segments: List<Segment>,
val decryptionInfo: DecryptionInfo? = null
) { ) {
fun buildM3U8(): String = buildString { fun buildM3U8(): String = buildString {
append("#EXTM3U\n") append("#EXTM3U\n")

View file

@ -412,24 +412,12 @@ class StateApp {
} }
if (Settings.instance.synchronization.enabled) { if (Settings.instance.synchronization.enabled) {
StateSync.instance.start(context, { StateSync.instance.start(context)
try {
UIDialogs.toast("Failed to start sync, port in use")
} catch (e: Throwable) {
//Ignored
}
})
} }
settingsActivityClosed.subscribe { settingsActivityClosed.subscribe {
if (Settings.instance.synchronization.enabled) { if (Settings.instance.synchronization.enabled) {
StateSync.instance.start(context, { StateSync.instance.start(context)
try {
UIDialogs.toast("Failed to start sync, port in use")
} catch (e: Throwable) {
//Ignored
}
})
} else { } else {
StateSync.instance.stop() StateSync.instance.stop()
} }

View file

@ -51,7 +51,7 @@ class StateSync {
val deviceRemoved: Event1<String> = Event1() val deviceRemoved: Event1<String> = Event1()
val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2() val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2()
fun start(context: Context, onServerBindFail: () -> Unit) { fun start(context: Context) {
if (syncService != null) { if (syncService != null) {
Logger.i(TAG, "Already started.") Logger.i(TAG, "Already started.")
return return
@ -150,24 +150,14 @@ class StateSync {
} }
} }
syncService?.start(context, onServerBindFail) syncService?.start(context)
} }
fun showFailedToBindDialogIfNecessary(context: Context) { fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit) {
if (syncService?.serverSocketFailedToStart == true && Settings.instance.synchronization.localConnections) {
try {
UIDialogs.showDialogOk(context, R.drawable.ic_warning, "Local discovery unavailable, port was in use")
} catch (e: Throwable) {
//Ignored
}
}
}
fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit, onServerBindFail: () -> Unit) {
if (syncService == null) { if (syncService == null) {
UIDialogs.showConfirmationDialog(context, "Sync has not been enabled yet, would you like to enable sync?", { UIDialogs.showConfirmationDialog(context, "Sync has not been enabled yet, would you like to enable sync?", {
Settings.instance.synchronization.enabled = true Settings.instance.synchronization.enabled = true
start(context, onServerBindFail) start(context)
Settings.instance.save() Settings.instance.save()
onStarted.invoke() onStarted.invoke()
}, { }, {

View file

@ -72,6 +72,8 @@ class SyncService(
private val _lastConnectTimesMdns: MutableMap<String, Long> = mutableMapOf() private val _lastConnectTimesMdns: MutableMap<String, Long> = mutableMapOf()
private val _lastConnectTimesIp: MutableMap<String, Long> = mutableMapOf() private val _lastConnectTimesIp: MutableMap<String, Long> = mutableMapOf()
var serverSocketFailedToStart = false var serverSocketFailedToStart = false
var serverSocketStarted = false
var relayConnected = false
//TODO: Should sync mdns and casting mdns be merged? //TODO: Should sync mdns and casting mdns be merged?
//TODO: Decrease interval that devices are updated //TODO: Decrease interval that devices are updated
//TODO: Send less data //TODO: Send less data
@ -212,7 +214,7 @@ class SyncService(
var onData: ((SyncSession, UByte, UByte, ByteBuffer) -> Unit)? = null var onData: ((SyncSession, UByte, UByte, ByteBuffer) -> Unit)? = null
var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null
fun start(context: Context, onServerBindFail: (() -> Unit)? = null) { fun start(context: Context) {
if (_started) { if (_started) {
Logger.i(TAG, "Already started.") Logger.i(TAG, "Already started.")
return return
@ -273,10 +275,12 @@ class SyncService(
Logger.i(TAG, "Sync key pair initialized (public key = $publicKey)") Logger.i(TAG, "Sync key pair initialized (public key = $publicKey)")
serverSocketStarted = false
if (settings.bindListener) { if (settings.bindListener) {
startListener(onServerBindFail) startListener()
} }
relayConnected = false
if (settings.relayEnabled) { if (settings.relayEnabled) {
startRelayLoop() startRelayLoop()
} }
@ -286,13 +290,15 @@ class SyncService(
} }
} }
private fun startListener(onServerBindFail: (() -> Unit)? = null) { private fun startListener() {
serverSocketFailedToStart = false serverSocketFailedToStart = false
serverSocketStarted = false
_thread = Thread { _thread = Thread {
try { try {
val serverSocket = ServerSocket(settings.listenerPort) val serverSocket = ServerSocket(settings.listenerPort)
_serverSocket = serverSocket _serverSocket = serverSocket
serverSocketStarted = true
Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)") Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)")
while (_started) { while (_started) {
@ -300,10 +306,12 @@ class SyncService(
val session = createSocketSession(socket, true) val session = createSocketSession(socket, true)
session.startAsResponder() session.startAsResponder()
} }
serverSocketStarted = false
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e) Logger.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e)
serverSocketFailedToStart = true serverSocketFailedToStart = true
onServerBindFail?.invoke() serverSocketStarted = false
} }
}.apply { start() } }.apply { start() }
} }
@ -386,13 +394,16 @@ class SyncService(
} }
private fun startRelayLoop() { private fun startRelayLoop() {
relayConnected = false
_threadRelay = Thread { _threadRelay = Thread {
try {
var backoffs: Array<Long> = arrayOf(1000, 5000, 10000, 20000) var backoffs: Array<Long> = arrayOf(1000, 5000, 10000, 20000)
var backoffIndex = 0; var backoffIndex = 0;
while (_started) { while (_started) {
try { try {
Log.i(TAG, "Starting relay session...") Log.i(TAG, "Starting relay session...")
relayConnected = false
var socketClosed = false; var socketClosed = false;
val socket = Socket(relayServer, 9000) val socket = Socket(relayServer, 9000)
@ -400,22 +411,38 @@ class SyncService(
(socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!, (socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!,
keyPair!!, keyPair!!,
socket, socket,
isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId -> isHandshakeAllowed(linkType, syncSocketSession, publicKey, pairingCode, appId) }, isHandshakeAllowed = { linkType, syncSocketSession, publicKey, pairingCode, appId ->
isHandshakeAllowed(
linkType,
syncSocketSession,
publicKey,
pairingCode,
appId
)
},
onNewChannel = { _, c -> onNewChannel = { _, c ->
val remotePublicKey = c.remotePublicKey val remotePublicKey = c.remotePublicKey
if (remotePublicKey == null) { if (remotePublicKey == null) {
Log.e(TAG, "Remote public key should never be null in onNewChannel.") Log.e(
TAG,
"Remote public key should never be null in onNewChannel."
)
return@SyncSocketSession return@SyncSocketSession
} }
Log.i(TAG, "New channel established from relay (pk: '$remotePublicKey').") Log.i(
TAG,
"New channel established from relay (pk: '$remotePublicKey')."
)
var session: SyncSession? var session: SyncSession?
synchronized(_sessions) { synchronized(_sessions) {
session = _sessions[remotePublicKey] session = _sessions[remotePublicKey]
if (session == null) { if (session == null) {
val remoteDeviceName = database.getDeviceName(remotePublicKey) val remoteDeviceName =
session = createNewSyncSession(remotePublicKey, remoteDeviceName) database.getDeviceName(remotePublicKey)
session =
createNewSyncSession(remotePublicKey, remoteDeviceName)
_sessions[remotePublicKey] = session!! _sessions[remotePublicKey] = session!!
} }
session!!.addChannel(c) session!!.addChannel(c)
@ -438,23 +465,57 @@ class SyncService(
Thread { Thread {
try { try {
while (_started && !socketClosed) { while (_started && !socketClosed) {
val unconnectedAuthorizedDevices = database.getAllAuthorizedDevices()?.filter { !isConnected(it) }?.toTypedArray() ?: arrayOf() val unconnectedAuthorizedDevices =
relaySession.publishConnectionInformation(unconnectedAuthorizedDevices, settings.listenerPort, settings.relayConnectDirect, false, false, settings.relayConnectRelayed) database.getAllAuthorizedDevices()
?.filter { !isConnected(it) }?.toTypedArray()
?: arrayOf()
relaySession.publishConnectionInformation(
unconnectedAuthorizedDevices,
settings.listenerPort,
settings.relayConnectDirect,
false,
false,
settings.relayConnectRelayed
)
Logger.v(TAG, "Requesting ${unconnectedAuthorizedDevices.size} devices connection information") Logger.v(
val connectionInfos = runBlocking { relaySession.requestBulkConnectionInfo(unconnectedAuthorizedDevices) } TAG,
Logger.v(TAG, "Received ${connectionInfos.size} devices connection information") "Requesting ${unconnectedAuthorizedDevices.size} devices connection information"
)
val connectionInfos = runBlocking {
relaySession.requestBulkConnectionInfo(
unconnectedAuthorizedDevices
)
}
Logger.v(
TAG,
"Received ${connectionInfos.size} devices connection information"
)
for ((targetKey, connectionInfo) in connectionInfos) { for ((targetKey, connectionInfo) in connectionInfos) {
val potentialLocalAddresses = connectionInfo.ipv4Addresses val potentialLocalAddresses =
connectionInfo.ipv4Addresses
.filter { it != connectionInfo.remoteIp } .filter { it != connectionInfo.remoteIp }
if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) { if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) {
Thread { Thread {
try { try {
Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.") Log.v(
connect(potentialLocalAddresses.map { it }.toTypedArray(), settings.listenerPort, targetKey, null) TAG,
"Attempting to connect directly, locally to '$targetKey'."
)
connect(
potentialLocalAddresses.map { it }
.toTypedArray(),
settings.listenerPort,
targetKey,
null
)
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "Failed to start direct connection using connection info with $targetKey.", e) Log.e(
TAG,
"Failed to start direct connection using connection info with $targetKey.",
e
)
} }
}.start() }.start()
} }
@ -469,10 +530,23 @@ class SyncService(
if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) { if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) {
try { try {
Logger.v(TAG, "Attempting relayed connection with '$targetKey'.") Logger.v(
runBlocking { relaySession.startRelayedChannel(targetKey, appId, null) } TAG,
"Attempting relayed connection with '$targetKey'."
)
runBlocking {
relaySession.startRelayedChannel(
targetKey,
appId,
null
)
}
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to start relayed channel with $targetKey.", e) Logger.e(
TAG,
"Failed to start relayed channel with $targetKey.",
e
)
} }
} }
} }
@ -491,17 +565,22 @@ class SyncService(
override val isAuthorized: Boolean get() = true override val isAuthorized: Boolean get() = true
} }
relayConnected = true
_relaySession!!.runAsInitiator(relayPublicKey, appId, null) _relaySession!!.runAsInitiator(relayPublicKey, appId, null)
Log.i(TAG, "Started relay session.") Log.i(TAG, "Started relay session.")
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "Relay session failed.", e) Log.e(TAG, "Relay session failed.", e)
} finally { } finally {
relayConnected = false
_relaySession?.stop() _relaySession?.stop()
_relaySession = null _relaySession = null
Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)]) Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)])
} }
} }
} catch (ex: Throwable) {
Log.i(TAG, "Unhandled exception in relay loop.", ex)
}
}.apply { start() } }.apply { start() }
} }

View file

@ -529,7 +529,7 @@ class SyncSocketSession {
val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true) val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true)
if (!isAllowed) { if (!isAllowed) {
val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN) val rp = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN)
rp.putInt(2) // Status code for not allowed rp.putInt(7) // Status code for not allowed
rp.putLong(connectionId) rp.putLong(connectionId)
rp.putInt(requestId) rp.putInt(requestId)
rp.rewind() rp.rewind()

View file

@ -3,6 +3,8 @@ package com.futo.platformplayer.views
import android.content.Context import android.content.Context
import android.text.TextWatcher import android.text.TextWatcher
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageButton import android.widget.ImageButton
@ -30,9 +32,26 @@ class SearchView : FrameLayout {
textSearch = findViewById(R.id.edit_search) textSearch = findViewById(R.id.edit_search)
buttonClear = findViewById(R.id.button_clear_search) buttonClear = findViewById(R.id.button_clear_search)
buttonClear.setOnClickListener { textSearch.text = "" }; buttonClear.setOnClickListener {
textSearch.text = ""
textSearch?.clearFocus()
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(textSearch.windowToken, 0)
onSearchChanged.emit("")
onEnter.emit("")
}
textSearch.setOnEditorActionListener { _, i, _ ->
if (i == EditorInfo.IME_ACTION_DONE) {
textSearch?.clearFocus()
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(textSearch.windowToken, 0)
onEnter.emit(textSearch.text.toString())
return@setOnEditorActionListener true
}
return@setOnEditorActionListener false
}
textSearch.addTextChangedListener { textSearch.addTextChangedListener {
onSearchChanged.emit(it.toString()); buttonClear.visibility = if ((it?.length ?: 0) > 0) View.VISIBLE else View.GONE
onSearchChanged.emit(it.toString())
}; };
} }
} }

View file

@ -28,17 +28,14 @@ class SlideUpMenuFilters {
private var _changed: Boolean = false; private var _changed: Boolean = false;
private val _lifecycleScope: CoroutineScope; private val _lifecycleScope: CoroutineScope;
private var _isChannelSearch = false;
var commonCapabilities: ResultCapabilities? = null; var commonCapabilities: ResultCapabilities? = null;
constructor(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>, isChannelSearch: Boolean = false) { constructor(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>) {
_lifecycleScope = lifecycleScope; _lifecycleScope = lifecycleScope;
_container = container; _container = container;
_enabledClientsIds = enabledClientsIds; _enabledClientsIds = enabledClientsIds;
_filterValues = filterValues; _filterValues = filterValues;
_isChannelSearch = isChannelSearch;
_slideUpMenuOverlay = SlideUpMenuOverlay(_container.context, _container, container.context.getString(R.string.filters), container.context.getString(R.string.done), true, listOf()); _slideUpMenuOverlay = SlideUpMenuOverlay(_container.context, _container, container.context.getString(R.string.filters), container.context.getString(R.string.done), true, listOf());
_slideUpMenuOverlay.onOK.subscribe { _slideUpMenuOverlay.onOK.subscribe {
onOK.emit(_enabledClientsIds, _changed); onOK.emit(_enabledClientsIds, _changed);
@ -51,10 +48,7 @@ class SlideUpMenuFilters {
private fun updateCommonCapabilities() { private fun updateCommonCapabilities() {
_lifecycleScope.launch(Dispatchers.IO) { _lifecycleScope.launch(Dispatchers.IO) {
try { try {
val caps = if(!_isChannelSearch) val caps = StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds);
StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds);
else
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(_enabledClientsIds);
synchronized(_filterValues) { synchronized(_filterValues) {
if (caps != null) { if (caps != null) {
val keysToRemove = arrayListOf<String>(); val keysToRemove = arrayListOf<String>();