mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2025-09-18 15:32:35 +00:00
Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay
This commit is contained in:
commit
7d5c8347ce
56 changed files with 2476 additions and 367 deletions
6
.gitmodules
vendored
6
.gitmodules
vendored
|
@ -100,3 +100,9 @@
|
|||
[submodule "app/src/unstable/assets/sources/curiositystream"]
|
||||
path = app/src/unstable/assets/sources/curiositystream
|
||||
url = ../plugins/curiositystream.git
|
||||
[submodule "app/src/unstable/assets/sources/crunchyroll"]
|
||||
path = app/src/unstable/assets/sources/crunchyroll
|
||||
url = ../plugins/crunchyroll.git
|
||||
[submodule "app/src/stable/assets/sources/crunchyroll"]
|
||||
path = app/src/stable/assets/sources/crunchyroll
|
||||
url = ../plugins/crunchyroll.git
|
||||
|
|
|
@ -198,6 +198,7 @@ dependencies {
|
|||
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
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 'com.github.dhaval2404:imagepicker:2.1'
|
||||
implementation 'com.google.zxing:core:3.4.1'
|
||||
|
|
|
@ -56,7 +56,7 @@
|
|||
|
||||
<activity
|
||||
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:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||
android:launchMode="singleInstance"
|
||||
|
|
|
@ -32,7 +32,8 @@ let Type = {
|
|||
Text: {
|
||||
RAW: 0,
|
||||
HTML: 1,
|
||||
MARKUP: 2
|
||||
MARKUP: 2,
|
||||
CODE: 3
|
||||
},
|
||||
Chapter: {
|
||||
NORMAL: 0,
|
||||
|
@ -291,15 +292,39 @@ class PlatformPostDetails extends PlatformPost {
|
|||
}
|
||||
}
|
||||
|
||||
class PlatformArticleDetails extends PlatformContent {
|
||||
class PlatformWeb extends PlatformContent {
|
||||
constructor(obj) {
|
||||
super(obj, 7);
|
||||
obj = obj ?? {};
|
||||
this.plugin_type = "PlatformWeb";
|
||||
}
|
||||
}
|
||||
class PlatformWebDetails extends PlatformWeb {
|
||||
constructor(obj) {
|
||||
super(obj, 7);
|
||||
obj = obj ?? {};
|
||||
this.plugin_type = "PlatformWebDetails";
|
||||
this.html = obj.html;
|
||||
}
|
||||
}
|
||||
|
||||
class PlatformArticle extends PlatformContent {
|
||||
constructor(obj) {
|
||||
super(obj, 3);
|
||||
obj = obj ?? {};
|
||||
this.plugin_type = "PlatformArticle";
|
||||
this.rating = obj.rating ?? new RatingLikes(-1);
|
||||
this.summary = obj.summary ?? "";
|
||||
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
|
||||
}
|
||||
}
|
||||
class PlatformArticleDetails extends PlatformArticle {
|
||||
constructor(obj) {
|
||||
super(obj, 3);
|
||||
obj = obj ?? {};
|
||||
this.plugin_type = "PlatformArticleDetails";
|
||||
this.rating = obj.rating ?? new RatingLikes(-1);
|
||||
this.summary = obj.summary ?? "";
|
||||
this.segments = obj.segments ?? [];
|
||||
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
|
||||
}
|
||||
}
|
||||
class ArticleSegment {
|
||||
|
@ -315,9 +340,10 @@ class ArticleTextSegment extends ArticleSegment {
|
|||
}
|
||||
}
|
||||
class ArticleImagesSegment extends ArticleSegment {
|
||||
constructor(images) {
|
||||
constructor(images, caption) {
|
||||
super(2);
|
||||
this.images = images;
|
||||
this.caption = caption;
|
||||
}
|
||||
}
|
||||
class ArticleNestedSegment extends ArticleSegment {
|
||||
|
|
|
@ -4,8 +4,14 @@ import android.app.NotificationManager
|
|||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
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 com.futo.platformplayer.activities.MainActivity
|
||||
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.SubscriptionGroup
|
||||
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.StateDownloads
|
||||
import com.futo.platformplayer.states.StateHistory
|
||||
|
@ -63,6 +72,8 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import androidx.core.net.toUri
|
||||
|
||||
class UISlideOverlays {
|
||||
companion object {
|
||||
|
@ -299,6 +310,7 @@ class UISlideOverlays {
|
|||
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun showHlsPicker(video: IPlatformVideoDetails, source: Any, sourceUrl: String, container: ViewGroup): SlideUpMenuOverlay {
|
||||
val items = arrayListOf<View>(LoaderView(container.context))
|
||||
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()
|
||||
?: throw Exception("Master playlist content is empty")
|
||||
|
||||
val resolvedPlaylistUrl = masterPlaylistResponse.url
|
||||
|
||||
val videoButtons = arrayListOf<SlideUpMenuItem>()
|
||||
val audioButtons = arrayListOf<SlideUpMenuItem>()
|
||||
//TODO: Implement subtitles
|
||||
|
@ -322,7 +336,54 @@ class UISlideOverlays {
|
|||
|
||||
val masterPlaylist: HLS.MasterPlaylist
|
||||
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 ->
|
||||
|
||||
|
@ -372,6 +433,7 @@ class UISlideOverlays {
|
|||
invokeParent = false
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
val newItems = arrayListOf<View>()
|
||||
if (videoButtons.isNotEmpty()) {
|
||||
|
@ -398,11 +460,11 @@ class UISlideOverlays {
|
|||
if (masterPlaylistContent.lines().any { it.startsWith("#EXTINF:") }) {
|
||||
withContext(Dispatchers.Main) {
|
||||
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")
|
||||
slideUpMenuOverlay.hide()
|
||||
} 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")
|
||||
slideUpMenuOverlay.hide()
|
||||
} else {
|
||||
|
@ -984,20 +1046,24 @@ class UISlideOverlays {
|
|||
+ actions).filterNotNull()
|
||||
));
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuGroup(
|
||||
container.context, container.context.getString(R.string.add_to), "addto",
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_queue_add,
|
||||
container.context.getString(R.string.add_to_queue),
|
||||
"${queue.size} " + container.context.getString(R.string.videos),
|
||||
tag = "queue",
|
||||
call = { StatePlayer.instance.addToQueue(video); }),
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_watchlist_add,
|
||||
"${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "",
|
||||
"${watchLater.size} " + container.context.getString(R.string.videos),
|
||||
tag = "watch later",
|
||||
call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video), true); }),
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_history,
|
||||
container.context.getString(R.string.add_to_history),
|
||||
"Mark as watched",
|
||||
|
@ -1067,14 +1133,17 @@ class UISlideOverlays {
|
|||
val queue = StatePlayer.instance.getQueue();
|
||||
val watchLater = StatePlaylists.instance.getWatchLater();
|
||||
items.add(
|
||||
SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other",
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuGroup(
|
||||
container.context, container.context.getString(R.string.other), "other",
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_queue_add,
|
||||
container.context.getString(R.string.queue),
|
||||
"${queue.size} " + container.context.getString(R.string.videos),
|
||||
tag = "queue",
|
||||
call = { StatePlayer.instance.addToQueue(video); }),
|
||||
SlideUpMenuItem(container.context,
|
||||
SlideUpMenuItem(
|
||||
container.context,
|
||||
R.drawable.ic_watchlist_add,
|
||||
StatePlayer.TYPE_WATCHLATER,
|
||||
"${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() };
|
||||
}
|
||||
|
||||
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>, isChannelSearch: Boolean = false): SlideUpMenuFilters {
|
||||
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues, isChannelSearch);
|
||||
fun showFiltersOverlay(lifecycleScope: CoroutineScope, container: ViewGroup, enabledClientsIds: List<String>, filterValues: HashMap<String, List<String>>): SlideUpMenuFilters {
|
||||
val overlay = SlideUpMenuFilters(lifecycleScope, container, enabledClientsIds, filterValues);
|
||||
overlay.show();
|
||||
return overlay;
|
||||
}
|
||||
|
|
|
@ -31,6 +31,12 @@ import java.io.ByteArrayOutputStream
|
|||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.InterfaceAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.SocketException
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.SecureRandom
|
||||
import java.time.OffsetDateTime
|
||||
|
@ -332,3 +338,97 @@ fun ByteArray.fromGzip(): ByteArray {
|
|||
}
|
||||
return outputStream.toByteArray()
|
||||
}
|
||||
|
||||
fun findPreferredAddress(): InetAddress? {
|
||||
val candidates = NetworkInterface.getNetworkInterfaces()
|
||||
.toList()
|
||||
.asSequence()
|
||||
.filter(::isUsableInterface)
|
||||
.flatMap { nif ->
|
||||
nif.interfaceAddresses
|
||||
.asSequence()
|
||||
.mapNotNull { ia ->
|
||||
ia.address.takeIf(::isUsableAddress)?.let { addr ->
|
||||
nif to ia
|
||||
}
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
|
||||
return candidates
|
||||
.minWithOrNull(
|
||||
compareBy<Pair<NetworkInterface, InterfaceAddress>>(
|
||||
{ addressScore(it.second.address) },
|
||||
{ interfaceScore(it.first) },
|
||||
{ -it.second.networkPrefixLength.toInt() },
|
||||
{ -it.first.mtu }
|
||||
)
|
||||
)?.second?.address
|
||||
}
|
||||
|
||||
private fun isUsableInterface(nif: NetworkInterface): Boolean {
|
||||
val name = nif.name.lowercase()
|
||||
return try {
|
||||
// must be up, not loopback/virtual/PtP, have a MAC, not Docker/tun/etc.
|
||||
nif.isUp
|
||||
&& !nif.isLoopback
|
||||
&& !nif.isPointToPoint
|
||||
&& !nif.isVirtual
|
||||
&& !name.startsWith("docker")
|
||||
&& !name.startsWith("veth")
|
||||
&& !name.startsWith("br-")
|
||||
&& !name.startsWith("virbr")
|
||||
&& !name.startsWith("vmnet")
|
||||
&& !name.startsWith("tun")
|
||||
&& !name.startsWith("tap")
|
||||
} catch (e: SocketException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isUsableAddress(addr: InetAddress): Boolean {
|
||||
return when {
|
||||
addr.isAnyLocalAddress -> false // 0.0.0.0 / ::
|
||||
addr.isLoopbackAddress -> false
|
||||
addr.isLinkLocalAddress -> false // 169.254.x.x or fe80::/10
|
||||
addr.isMulticastAddress -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
private fun interfaceScore(nif: NetworkInterface): Int {
|
||||
val name = nif.name.lowercase()
|
||||
return when {
|
||||
name.matches(Regex("^(eth|enp|eno|ens|em)\\d+")) -> 0
|
||||
name.startsWith("eth") || name.contains("ethernet") -> 0
|
||||
name.matches(Regex("^(wlan|wlp)\\d+")) -> 1
|
||||
name.contains("wi-fi") || name.contains("wifi") -> 1
|
||||
else -> 2
|
||||
}
|
||||
}
|
||||
|
||||
private fun addressScore(addr: InetAddress): Int {
|
||||
return when (addr) {
|
||||
is Inet4Address -> {
|
||||
val octets = addr.address.map { it.toInt() and 0xFF }
|
||||
when {
|
||||
octets[0] == 10 -> 0 // 10/8
|
||||
octets[0] == 192 && octets[1] == 168 -> 0 // 192.168/16
|
||||
octets[0] == 172 && octets[1] in 16..31 -> 0 // 172.16–31/12
|
||||
else -> 1 // public IPv4
|
||||
}
|
||||
}
|
||||
is Inet6Address -> {
|
||||
// ULA (fc00::/7) vs global vs others
|
||||
val b0 = addr.address[0].toInt() and 0xFF
|
||||
when {
|
||||
(b0 and 0xFE) == 0xFC -> 2 // ULA
|
||||
(b0 and 0xE0) == 0x20 -> 3 // global
|
||||
else -> 4
|
||||
}
|
||||
}
|
||||
else -> Int.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
|
|
@ -25,7 +25,6 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.constraintlayout.motion.widget.MotionLayout
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
|
@ -33,6 +32,8 @@ import androidx.fragment.app.Fragment
|
|||
import androidx.fragment.app.FragmentContainerView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.whenStateAtLeast
|
||||
import androidx.lifecycle.withStateAtLeast
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.BuildConfig
|
||||
import com.futo.platformplayer.R
|
||||
|
@ -43,6 +44,7 @@ import com.futo.platformplayer.casting.StateCasting
|
|||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ArticleDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.BrowserFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.BuyFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.ChannelFragment
|
||||
|
@ -71,6 +73,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.TutorialFragment
|
|||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.VideoDetailFragment.State
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.WatchLaterFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.main.WebDetailFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.AddTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.GeneralTopBarFragment
|
||||
import com.futo.platformplayer.fragment.mainactivity.topbar.ImportTopBarFragment
|
||||
|
@ -150,6 +153,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
//Frags Main
|
||||
lateinit var _fragMainHome: HomeFragment;
|
||||
lateinit var _fragPostDetail: PostDetailFragment;
|
||||
lateinit var _fragArticleDetail: ArticleDetailFragment;
|
||||
lateinit var _fragWebDetail: WebDetailFragment;
|
||||
lateinit var _fragMainVideoSearchResults: ContentSearchResultsFragment;
|
||||
lateinit var _fragMainCreatorSearchResults: CreatorSearchResultsFragment;
|
||||
lateinit var _fragMainPlaylistSearchResults: PlaylistSearchResultsFragment;
|
||||
|
@ -203,7 +208,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
}
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
lifecycleScope.launch {
|
||||
handleUrlAll(content)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
|
@ -280,7 +285,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
|
||||
runBlocking {
|
||||
try {
|
||||
StatePlatform.instance.updateAvailableClients(this@MainActivity);
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in updateAvailableClients", e)
|
||||
}
|
||||
}
|
||||
|
||||
//Preload common files to memory
|
||||
|
@ -324,6 +333,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
_fragMainPlaylist = PlaylistFragment.newInstance();
|
||||
_fragMainRemotePlaylist = RemotePlaylistFragment.newInstance();
|
||||
_fragPostDetail = PostDetailFragment.newInstance();
|
||||
_fragArticleDetail = ArticleDetailFragment.newInstance();
|
||||
_fragWebDetail = WebDetailFragment.newInstance();
|
||||
_fragWatchlist = WatchLaterFragment.newInstance();
|
||||
_fragHistory = HistoryFragment.newInstance();
|
||||
_fragSourceDetail = SourceDetailFragment.newInstance();
|
||||
|
@ -450,6 +461,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
_fragMainPlaylist.topBar = _fragTopBarNavigation;
|
||||
_fragMainRemotePlaylist.topBar = _fragTopBarNavigation;
|
||||
_fragPostDetail.topBar = _fragTopBarNavigation;
|
||||
_fragArticleDetail.topBar = _fragTopBarNavigation;
|
||||
_fragWebDetail.topBar = _fragTopBarNavigation;
|
||||
_fragWatchlist.topBar = _fragTopBarNavigation;
|
||||
_fragHistory.topBar = _fragTopBarNavigation;
|
||||
_fragSourceDetail.topBar = _fragTopBarNavigation;
|
||||
|
@ -707,7 +720,7 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
|
||||
"VIDEO" -> {
|
||||
val url = intent.getStringExtra("VIDEO");
|
||||
navigate(_fragVideoDetail, url);
|
||||
navigateWhenReady(_fragVideoDetail, url);
|
||||
}
|
||||
|
||||
"IMPORT_OPTIONS" -> {
|
||||
|
@ -725,11 +738,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
"Sources" -> {
|
||||
runBlocking {
|
||||
StatePlatform.instance.updateAvailableClients(this@MainActivity, true) //Ideally this is not needed..
|
||||
navigate(_fragMainSources);
|
||||
navigateWhenReady(_fragMainSources);
|
||||
}
|
||||
};
|
||||
"BROWSE_PLUGINS" -> {
|
||||
navigate(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
|
||||
navigateWhenReady(_fragBrowser, BrowserFragment.NavigateOptions("https://plugins.grayjay.app/phone.html", mapOf(
|
||||
Pair("grayjay") { req ->
|
||||
StateApp.instance.contextOrNull?.let {
|
||||
if (it is MainActivity) {
|
||||
|
@ -747,8 +760,12 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
|
||||
try {
|
||||
if (targetData != null) {
|
||||
runBlocking {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
handleUrlAll(targetData)
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Unhandled exception in handleUrlAll", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
|
@ -776,10 +793,10 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
startActivity(intent);
|
||||
} else if (url.startsWith("grayjay://video/")) {
|
||||
val videoUrl = url.substring("grayjay://video/".length);
|
||||
navigate(_fragVideoDetail, videoUrl);
|
||||
navigateWhenReady(_fragVideoDetail, videoUrl);
|
||||
} else if (url.startsWith("grayjay://channel/")) {
|
||||
val channelUrl = url.substring("grayjay://channel/".length);
|
||||
navigate(_fragMainChannel, channelUrl);
|
||||
navigateWhenReady(_fragMainChannel, channelUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -845,29 +862,29 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
|
||||
return withContext(Dispatchers.IO) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) on IO");
|
||||
if (StatePlatform.instance.hasEnabledVideoClient(url)) {
|
||||
if (StatePlatform.instance.hasEnabledContentClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found video client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (position > 0)
|
||||
navigate(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
|
||||
navigateWhenReady(_fragVideoDetail, UrlVideoWithTime(url, position.toLong(), true));
|
||||
else
|
||||
navigate(_fragVideoDetail, url);
|
||||
navigateWhenReady(_fragVideoDetail, url);
|
||||
|
||||
_fragVideoDetail.maximizeVideoDetail(true);
|
||||
}
|
||||
return@withContext true;
|
||||
} else if (StatePlatform.instance.hasEnabledChannelClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found channel client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
navigate(_fragMainChannel, url);
|
||||
withContext(Dispatchers.Main) {
|
||||
navigateWhenReady(_fragMainChannel, url);
|
||||
delay(100);
|
||||
_fragVideoDetail.minimizeVideoDetail();
|
||||
};
|
||||
return@withContext true;
|
||||
} else if (StatePlatform.instance.hasEnabledPlaylistClient(url)) {
|
||||
Logger.i(TAG, "handleUrl(url=$url) found playlist client");
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
navigate(_fragMainRemotePlaylist, url);
|
||||
withContext(Dispatchers.Main) {
|
||||
navigateWhenReady(_fragMainRemotePlaylist, url);
|
||||
delay(100);
|
||||
_fragVideoDetail.minimizeVideoDetail();
|
||||
};
|
||||
|
@ -1094,6 +1111,18 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
return fragCurrent is T;
|
||||
}
|
||||
|
||||
fun navigateWhenReady(segment: MainFragment, parameter: Any? = null, withHistory: Boolean = true, isBack: Boolean = false) {
|
||||
if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
navigate(segment, parameter, withHistory, isBack)
|
||||
} else {
|
||||
lifecycleScope.launch {
|
||||
lifecycle.withStateAtLeast(Lifecycle.State.RESUMED) {
|
||||
navigate(segment, parameter, withHistory, isBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate takes a MainFragment, and makes them the current main visible view
|
||||
* A parameter can be provided which becomes available in the onShow of said fragment
|
||||
|
@ -1218,6 +1247,8 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
|
|||
PlaylistFragment::class -> _fragMainPlaylist as T;
|
||||
RemotePlaylistFragment::class -> _fragMainRemotePlaylist as T;
|
||||
PostDetailFragment::class -> _fragPostDetail as T;
|
||||
ArticleDetailFragment::class -> _fragArticleDetail as T;
|
||||
WebDetailFragment::class -> _fragWebDetail as T;
|
||||
WatchLaterFragment::class -> _fragWatchlist as T;
|
||||
HistoryFragment::class -> _fragHistory as T;
|
||||
SourceDetailFragment::class -> _fragSourceDetail as T;
|
||||
|
|
|
@ -9,6 +9,7 @@ import android.widget.LinearLayout
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
|
@ -100,12 +101,18 @@ class SyncHomeActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
StateSync.instance.confirmStarted(this, {
|
||||
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
|
||||
}, {
|
||||
StateSync.instance.confirmStarted(this, onStarted = {
|
||||
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()
|
||||
}, {
|
||||
StateSync.instance.showFailedToBindDialogIfNecessary(this@SyncHomeActivity)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
package com.futo.platformplayer.api.media.models.article
|
||||
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
|
||||
interface IPlatformArticle: IPlatformContent {
|
||||
val summary: String?;
|
||||
val thumbnails: Thumbnails?;
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package com.futo.platformplayer.api.media.models.article
|
||||
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.IJSArticleSegment
|
||||
|
||||
interface IPlatformArticleDetails: IPlatformContent, IPlatformArticle, IPlatformContentDetails {
|
||||
val segments: List<IJSArticleSegment>;
|
||||
val rating : IRating;
|
||||
}
|
|
@ -8,6 +8,7 @@ enum class ContentType(val value: Int) {
|
|||
POST(2),
|
||||
ARTICLE(3),
|
||||
PLAYLIST(4),
|
||||
WEB(7),
|
||||
|
||||
URL(9),
|
||||
|
||||
|
|
|
@ -5,7 +5,8 @@ import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException
|
|||
enum class TextType(val value: Int) {
|
||||
RAW(0),
|
||||
HTML(1),
|
||||
MARKUP(2);
|
||||
MARKUP(2),
|
||||
CODE(3);
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int): TextType
|
||||
|
|
|
@ -27,7 +27,9 @@ interface IJSContent: IPlatformContent {
|
|||
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
|
||||
ContentType.PLAYLIST -> JSPlaylist(config, obj);
|
||||
ContentType.LOCKED -> JSLockedContent(config, obj);
|
||||
ContentType.CHANNEL -> JSChannelContent(config, obj)
|
||||
ContentType.CHANNEL -> JSChannelContent(config, obj);
|
||||
ContentType.ARTICLE -> JSArticle(config, obj);
|
||||
ContentType.WEB -> JSWeb(config, obj);
|
||||
else -> throw NotImplementedError("Unknown content type ${type}");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ interface IJSContentDetails: IPlatformContent {
|
|||
ContentType.MEDIA -> JSVideoDetails(plugin, obj);
|
||||
ContentType.POST -> JSPostDetails(plugin.config, obj);
|
||||
ContentType.ARTICLE -> JSArticleDetails(plugin, obj);
|
||||
ContentType.WEB -> JSWebDetails(plugin, obj);
|
||||
else -> throw NotImplementedError("Unknown content type ${type}");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.post.TextType
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSArticle : JSContent, IPlatformArticle, IPluginSourced {
|
||||
final override val contentType: ContentType get() = ContentType.ARTICLE;
|
||||
|
||||
override val summary: String;
|
||||
override val thumbnails: Thumbnails?;
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
||||
val contextName = "PlatformArticle";
|
||||
|
||||
summary = _content.getOrDefault(config, "summary", contextName, "") ?: "";
|
||||
thumbnails = Thumbnails.fromV8(config, _content.getOrThrow(config, "thumbnails", contextName));
|
||||
|
||||
}
|
||||
}
|
|
@ -4,6 +4,8 @@ import com.caoccao.javet.values.reference.V8ValueObject
|
|||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
|
||||
import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
|
@ -21,20 +23,20 @@ import com.futo.platformplayer.getOrThrow
|
|||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSArticleDetails : JSContent, IPluginSourced, IPlatformContentDetails {
|
||||
open class JSArticleDetails : JSContent, IPlatformArticleDetails, IPluginSourced, IPlatformContentDetails {
|
||||
final override val contentType: ContentType get() = ContentType.ARTICLE;
|
||||
|
||||
private val _hasGetComments: Boolean;
|
||||
private val _hasGetContentRecommendations: Boolean;
|
||||
|
||||
val rating: IRating;
|
||||
override val rating: IRating;
|
||||
|
||||
val summary: String;
|
||||
val thumbnails: Thumbnails?;
|
||||
val segments: List<IJSArticleSegment>;
|
||||
override val summary: String;
|
||||
override val thumbnails: Thumbnails?;
|
||||
override val segments: List<IJSArticleSegment>;
|
||||
|
||||
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
|
||||
val contextName = "PlatformPost";
|
||||
val contextName = "PlatformArticle";
|
||||
|
||||
rating = obj.getOrDefault<V8ValueObject>(client.config, "rating", contextName, null)?.let { IRating.fromV8(client.config, it, contextName) } ?: RatingLikes(0);
|
||||
summary = _content.getOrThrow(client.config, "summary", contextName);
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.post.TextType
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSWeb : JSContent, IPluginSourced {
|
||||
final override val contentType: ContentType get() = ContentType.WEB;
|
||||
|
||||
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
|
||||
val contextName = "PlatformWeb";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package com.futo.platformplayer.api.media.platforms.js.models
|
||||
|
||||
import com.caoccao.javet.values.reference.V8ValueObject
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||
import com.futo.platformplayer.api.media.models.post.TextType
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.platforms.js.DevJSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.getOrDefault
|
||||
import com.futo.platformplayer.getOrThrow
|
||||
import com.futo.platformplayer.getOrThrowNullableList
|
||||
import com.futo.platformplayer.states.StateDeveloper
|
||||
|
||||
open class JSWebDetails : JSContent, IPluginSourced, IPlatformContentDetails {
|
||||
final override val contentType: ContentType get() = ContentType.WEB;
|
||||
|
||||
val html: String?;
|
||||
//TODO: Options?
|
||||
|
||||
|
||||
constructor(client: JSClient, obj: V8ValueObject): super(client.config, obj) {
|
||||
val contextName = "PlatformWeb";
|
||||
|
||||
html = obj.getOrDefault(client.config, "html", contextName, null);
|
||||
}
|
||||
|
||||
override fun getComments(client: IPlatformClient): IPager<IPlatformComment>? = null;
|
||||
override fun getPlaybackTracker(): IPlaybackTracker? = null;
|
||||
override fun getContentRecommendations(client: IPlatformClient): IPager<IPlatformContent>? = null;
|
||||
|
||||
}
|
|
@ -10,7 +10,9 @@ import com.futo.platformplayer.toHexString
|
|||
import com.futo.platformplayer.toInetAddress
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
|
@ -56,6 +58,10 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
private var _mediaSessionId: Int? = null;
|
||||
private var _thread: Thread? = null;
|
||||
private var _pingThread: Thread? = null;
|
||||
private var _launchRetries = 0
|
||||
private val MAX_LAUNCH_RETRIES = 3
|
||||
private var _lastLaunchTime_ms = 0L
|
||||
private var _retryJob: Job? = null
|
||||
|
||||
constructor(name: String, addresses: Array<InetAddress>, port: Int) : super() {
|
||||
this.name = name;
|
||||
|
@ -229,6 +235,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
launchObject.put("appId", "CC1AD845");
|
||||
launchObject.put("requestId", _requestId++);
|
||||
sendChannelMessage("sender-0", "receiver-0", "urn:x-cast:com.google.cast.receiver", launchObject.toString());
|
||||
_lastLaunchTime_ms = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
private fun getStatus() {
|
||||
|
@ -268,6 +275,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
_contentType = null;
|
||||
_streamType = null;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_transportId = null;
|
||||
}
|
||||
|
||||
|
@ -282,6 +290,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
|
||||
_started = true;
|
||||
_sessionId = null;
|
||||
_launchRetries = 0
|
||||
_mediaSessionId = null;
|
||||
|
||||
Logger.i(TAG, "Starting...");
|
||||
|
@ -393,7 +402,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
try {
|
||||
val inputStream = _inputStream ?: break;
|
||||
|
||||
synchronized(_inputStreamLock)
|
||||
val message = synchronized(_inputStreamLock)
|
||||
{
|
||||
Log.d(TAG, "Receiving next packet...");
|
||||
val b1 = inputStream.readUnsignedByte();
|
||||
|
@ -405,7 +414,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
if (size > buffer.size) {
|
||||
Logger.w(TAG, "Skipping packet that is too large $size bytes.")
|
||||
inputStream.skip(size.toLong());
|
||||
return@synchronized
|
||||
return@synchronized null
|
||||
}
|
||||
|
||||
Log.d(TAG, "Received header indicating $size bytes. Waiting for message.");
|
||||
|
@ -414,15 +423,19 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
//TODO: In the future perhaps this size-1 will cause issues, why is there a 0 on the end?
|
||||
val messageBytes = buffer.sliceArray(IntRange(0, size - 1));
|
||||
Log.d(TAG, "Received $size bytes: ${messageBytes.toHexString()}.");
|
||||
val message = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (message.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $message");
|
||||
val msg = ChromeCast.CastMessage.parseFrom(messageBytes);
|
||||
if (msg.namespace != "urn:x-cast:com.google.cast.tp.heartbeat") {
|
||||
Logger.i(TAG, "Received message: $msg");
|
||||
}
|
||||
return@synchronized msg
|
||||
}
|
||||
|
||||
if (message != null) {
|
||||
try {
|
||||
handleMessage(message);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to handle message.", e);
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e: java.net.SocketException) {
|
||||
|
@ -512,6 +525,7 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
if (_sessionId == null) {
|
||||
connectionState = CastConnectionState.CONNECTED;
|
||||
_sessionId = applicationUpdate.getString("sessionId");
|
||||
_launchRetries = 0
|
||||
|
||||
val transportId = applicationUpdate.getString("transportId");
|
||||
connectMediaChannel(transportId);
|
||||
|
@ -526,21 +540,40 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
}
|
||||
|
||||
if (!sessionIsRunning) {
|
||||
_sessionId = null;
|
||||
_mediaSessionId = null;
|
||||
setTime(0.0);
|
||||
_transportId = null;
|
||||
Logger.w(TAG, "Session not found.");
|
||||
if (System.currentTimeMillis() - _lastLaunchTime_ms > 5000) {
|
||||
_sessionId = null
|
||||
_mediaSessionId = null
|
||||
setTime(0.0)
|
||||
_transportId = null
|
||||
|
||||
if (_launching) {
|
||||
Logger.i(TAG, "Player not found, launching.");
|
||||
launchPlayer();
|
||||
if (_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
Logger.i(TAG, "No player yet; attempting launch #${_launchRetries + 1}")
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
} else if (!_launching && _launchRetries < MAX_LAUNCH_RETRIES) {
|
||||
// Maybe the first GET_STATUS came back empty; still try launching
|
||||
Logger.i(TAG, "Player not found; triggering launch #${_launchRetries + 1}")
|
||||
_launching = true
|
||||
_launchRetries++
|
||||
launchPlayer()
|
||||
} else {
|
||||
Logger.i(TAG, "Player not found, disconnecting.");
|
||||
stop();
|
||||
Logger.e(TAG, "Player not found after $_launchRetries attempts; giving up.")
|
||||
Logger.i(TAG, "Unable to start media receiver on device")
|
||||
stop()
|
||||
}
|
||||
} else {
|
||||
_launching = false;
|
||||
if (_retryJob == null) {
|
||||
Logger.i(TAG, "Scheduled retry job over 5 seconds")
|
||||
_retryJob = _scopeIO?.launch(Dispatchers.IO) {
|
||||
delay(5000)
|
||||
getStatus()
|
||||
_retryJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_launching = false
|
||||
_launchRetries = 0
|
||||
}
|
||||
|
||||
val volume = status.getJSONObject("volume");
|
||||
|
@ -582,6 +615,8 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
if (message.sourceId == "receiver-0") {
|
||||
Logger.i(TAG, "Close received.");
|
||||
stop();
|
||||
} else if (_transportId == message.sourceId) {
|
||||
throw Exception("Transport id closed.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -616,6 +651,9 @@ class ChromecastCastingDevice : CastingDevice {
|
|||
localAddress = null;
|
||||
_started = false;
|
||||
|
||||
_retryJob?.cancel()
|
||||
_retryJob = null
|
||||
|
||||
val socket = _socket;
|
||||
val scopeIO = _scopeIO;
|
||||
|
||||
|
|
|
@ -10,6 +10,8 @@ import android.os.Build
|
|||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import java.net.NetworkInterface
|
||||
import java.net.Inet4Address
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.futo.platformplayer.R
|
||||
|
@ -41,6 +43,7 @@ import com.futo.platformplayer.builders.DashBuilder
|
|||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.Event2
|
||||
import com.futo.platformplayer.exceptions.UnsupportedCastException
|
||||
import com.futo.platformplayer.findPreferredAddress
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.models.CastingDeviceInfo
|
||||
import com.futo.platformplayer.parsers.HLS
|
||||
|
@ -55,9 +58,11 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
|
||||
class StateCasting {
|
||||
|
@ -483,7 +488,7 @@ class StateCasting {
|
|||
}
|
||||
} else {
|
||||
val proxyStreams = Settings.instance.casting.alwaysProxyRequests;
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
if (videoSource is IVideoUrlSource) {
|
||||
|
@ -578,7 +583,7 @@ class StateCasting {
|
|||
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
val videoPath = "/video-${id}"
|
||||
val videoUrl = url + videoPath;
|
||||
|
@ -597,7 +602,7 @@ class StateCasting {
|
|||
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
val audioPath = "/audio-${id}"
|
||||
val audioUrl = url + audioPath;
|
||||
|
@ -616,7 +621,7 @@ class StateCasting {
|
|||
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?): List<String> {
|
||||
val ad = activeDevice ?: return listOf()
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}"
|
||||
val url = getLocalUrl(ad)
|
||||
val id = UUID.randomUUID()
|
||||
|
||||
val hlsPath = "/hls-${id}"
|
||||
|
@ -712,7 +717,7 @@ class StateCasting {
|
|||
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double, speed: Double?) : List<String> {
|
||||
val ad = activeDevice ?: return listOf();
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
|
@ -762,7 +767,7 @@ class StateCasting {
|
|||
val ad = activeDevice ?: return listOf();
|
||||
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 videoPath = "/video-${id}"
|
||||
|
@ -827,7 +832,7 @@ class StateCasting {
|
|||
_castServer.removeAllHandlers("castProxiedHlsMaster")
|
||||
|
||||
val ad = activeDevice ?: return listOf();
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
|
||||
val id = UUID.randomUUID();
|
||||
val hlsPath = "/hls-${id}"
|
||||
|
@ -997,7 +1002,7 @@ class StateCasting {
|
|||
|
||||
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 url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val hlsPath = "/hls-${id}"
|
||||
|
@ -1127,7 +1132,7 @@ class StateCasting {
|
|||
val ad = activeDevice ?: return listOf();
|
||||
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 dashPath = "/dash-${id}"
|
||||
|
@ -1213,6 +1218,15 @@ class StateCasting {
|
|||
}
|
||||
}
|
||||
|
||||
private fun getLocalUrl(ad: CastingDevice): String {
|
||||
var address = ad.localAddress!!
|
||||
if (address is Inet6Address && address.isLinkLocalAddress) {
|
||||
address = findPreferredAddress() ?: address
|
||||
Logger.i(TAG, "Selected casting address: $address")
|
||||
}
|
||||
return "http://${address.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
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();
|
||||
|
@ -1220,7 +1234,7 @@ class StateCasting {
|
|||
cleanExecutors()
|
||||
_castServer.removeAllHandlers("castDashRaw")
|
||||
|
||||
val url = "http://${ad.localAddress.toUrlAddress().trim('/')}:${_castServer.port}";
|
||||
val url = getLocalUrl(ad);
|
||||
val id = UUID.randomUUID();
|
||||
|
||||
val dashPath = "/dash-${id}"
|
||||
|
|
|
@ -47,6 +47,7 @@ import com.futo.platformplayer.states.StatePlatform
|
|||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.toHumanBitrate
|
||||
import com.futo.platformplayer.toHumanBytesSpeed
|
||||
import com.futo.polycentric.core.hexStringToByteArray
|
||||
import hasAnySource
|
||||
import isDownloadable
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
@ -59,16 +60,21 @@ import kotlinx.coroutines.suspendCancellableCoroutine
|
|||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Transient
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.lang.Thread.sleep
|
||||
import java.nio.ByteBuffer
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.ForkJoinPool
|
||||
import java.util.concurrent.ForkJoinTask
|
||||
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.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 {
|
||||
if(targetFile.exists())
|
||||
targetFile.delete();
|
||||
|
@ -579,6 +593,14 @@ class VideoDownload {
|
|||
?: throw Exception("Variant playlist content is empty")
|
||||
|
||||
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 ->
|
||||
if (segment !is HLS.MediaSegment) {
|
||||
return@forEachIndexed
|
||||
|
@ -590,7 +612,7 @@ class VideoDownload {
|
|||
try {
|
||||
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 expectedTotalLength = averageSegmentLength * (variantPlaylist.segments.size - 1) + segmentLength
|
||||
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) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val fileList = File(context.cacheDir, "fileList-${UUID.randomUUID()}.txt")
|
||||
fileList.writeText(segmentFiles.joinToString("\n") { "file '${it.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 cmd =
|
||||
"-i \"concat:${segmentFiles.joinToString("|")}\" -c copy \"${targetFile.absolutePath}\""
|
||||
|
||||
val statisticsCallback = StatisticsCallback { _ ->
|
||||
//TODO: Show progress?
|
||||
|
@ -645,7 +663,6 @@ class VideoDownload {
|
|||
val session = FFmpegKit.executeAsync(cmd,
|
||||
{ session ->
|
||||
if (ReturnCode.isSuccess(session.returnCode)) {
|
||||
fileList.delete()
|
||||
continuation.resumeWith(Result.success(Unit))
|
||||
} else {
|
||||
val errorMessage = if (ReturnCode.isCancel(session.returnCode)) {
|
||||
|
@ -653,7 +670,6 @@ class VideoDownload {
|
|||
} else {
|
||||
"Command failed with state '${session.state}' and return code ${session.returnCode}, stack trace ${session.failStackTrace}"
|
||||
}
|
||||
fileList.delete()
|
||||
continuation.resumeWithException(RuntimeException(errorMessage))
|
||||
}
|
||||
},
|
||||
|
@ -773,7 +789,7 @@ class VideoDownload {
|
|||
else {
|
||||
Logger.i(TAG, "Download $name Sequential");
|
||||
try {
|
||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, onProgress);
|
||||
sourceLength = downloadSource_Sequential(client, fileStream, videoUrl, null, 0, onProgress);
|
||||
} catch (e: Throwable) {
|
||||
Logger.w(TAG, "Failed to download sequentially (url = $videoUrl)")
|
||||
throw e
|
||||
|
@ -800,7 +816,31 @@ class VideoDownload {
|
|||
}
|
||||
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;
|
||||
var lastProgressCount: Int = 0;
|
||||
val speedRate: Int = 4096 * 5;
|
||||
|
@ -820,6 +860,8 @@ class VideoDownload {
|
|||
val sourceLength = result.body.contentLength();
|
||||
val sourceStream = result.body.byteStream();
|
||||
|
||||
val segmentBuffer = ByteArrayOutputStream()
|
||||
|
||||
var totalRead: Long = 0;
|
||||
try {
|
||||
var read: Int;
|
||||
|
@ -830,7 +872,7 @@ class VideoDownload {
|
|||
if (read < 0)
|
||||
break;
|
||||
|
||||
fileStream.write(buffer, 0, read);
|
||||
segmentBuffer.write(buffer, 0, read);
|
||||
|
||||
totalRead += read;
|
||||
|
||||
|
@ -856,6 +898,21 @@ class VideoDownload {
|
|||
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);
|
||||
return sourceLength;
|
||||
}
|
||||
|
@ -1162,6 +1219,8 @@ class VideoDownload {
|
|||
fun audioContainerToExtension(container: String): String {
|
||||
if (container.contains("audio/mp4"))
|
||||
return "mp4a";
|
||||
else if (container.contains("video/mp4"))
|
||||
return "mp4";
|
||||
else if (container.contains("audio/mpeg"))
|
||||
return "mpga";
|
||||
else if (container.contains("audio/mp3"))
|
||||
|
@ -1169,7 +1228,7 @@ class VideoDownload {
|
|||
else if (container.contains("audio/webm"))
|
||||
return "webm";
|
||||
else if (container == "application/vnd.apple.mpegurl")
|
||||
return "mp4a";
|
||||
return "m4a";
|
||||
else
|
||||
return "audio";// throw IllegalStateException("Unknown container: " + container)
|
||||
}
|
||||
|
|
|
@ -69,7 +69,7 @@ class VideoExport {
|
|||
outputFile = f;
|
||||
} else if (v != null) {
|
||||
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.");
|
||||
|
||||
Logger.i(TAG, "Copying video.");
|
||||
|
@ -81,7 +81,7 @@ class VideoExport {
|
|||
outputFile = f;
|
||||
} else if (a != null) {
|
||||
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.");
|
||||
|
||||
Logger.i(TAG, "Copying audio.");
|
||||
|
|
|
@ -12,6 +12,7 @@ import com.futo.platformplayer.logging.Logger
|
|||
import com.futo.platformplayer.states.StateDeveloper
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||
import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
|
||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||
|
@ -77,6 +78,22 @@ class PackageBridge : V8Package {
|
|||
return "android";
|
||||
}
|
||||
|
||||
@V8Property
|
||||
fun supportedContent(): Array<Int> {
|
||||
return arrayOf(
|
||||
ContentType.MEDIA.value,
|
||||
ContentType.POST.value,
|
||||
ContentType.PLAYLIST.value,
|
||||
ContentType.WEB.value,
|
||||
ContentType.URL.value,
|
||||
ContentType.NESTED_VIDEO.value,
|
||||
ContentType.CHANNEL.value,
|
||||
ContentType.LOCKED.value,
|
||||
ContentType.PLACEHOLDER.value,
|
||||
ContentType.DEFERRED.value
|
||||
)
|
||||
}
|
||||
|
||||
@V8Function
|
||||
fun dispose(value: V8Value) {
|
||||
Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
|
||||
|
|
|
@ -5,6 +5,7 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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.Event2
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.engine.exceptions.PluginException
|
||||
import com.futo.platformplayer.engine.exceptions.ScriptCaptchaRequiredException
|
||||
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.states.StateCache
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlugins
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.states.StateSubscriptions
|
||||
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.InsertedViewAdapterWithLoader
|
||||
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 _adapterResults: InsertedViewAdapterWithLoader<ContentPreviewViewHolder>? = null;
|
||||
private var _lastPolycentricProfile: PolycentricProfile? = null;
|
||||
private var _query: String? = null
|
||||
private var _searchView: SearchView? = null
|
||||
|
||||
val onContentClicked = Event2<IPlatformContent, Long>();
|
||||
val onContentUrlClicked = Event2<String, ContentType>();
|
||||
|
@ -68,17 +74,32 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
|
||||
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;
|
||||
var pager: IPager<IPlatformContent>? = null;
|
||||
if (lastPolycentricProfile != null && StatePolycentric.instance.enabled)
|
||||
pager =
|
||||
StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType);
|
||||
if (lastPolycentricProfile != null && StatePolycentric.instance.enabled) {
|
||||
pager = StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = subType);
|
||||
Logger.i(TAG, "StatePolycentric.instance.getChannelContent(lifecycleScope, lastPolycentricProfile, type = ${subType})")
|
||||
}
|
||||
|
||||
if(pager == null) {
|
||||
if(subType != null)
|
||||
if(subType != null) {
|
||||
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);
|
||||
Logger.i(TAG, "StatePlatform.instance.getChannelContent(channel.url = ${channel.url})")
|
||||
}
|
||||
}
|
||||
}
|
||||
return pager;
|
||||
}
|
||||
|
@ -145,19 +166,49 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
|
||||
_taskLoadVideos.cancel();
|
||||
|
||||
_query = null
|
||||
_channel = channel;
|
||||
updateSearchViewVisibility()
|
||||
_results.clear();
|
||||
_adapterResults?.notifyDataSetChanged();
|
||||
|
||||
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? {
|
||||
val view = inflater.inflate(R.layout.fragment_channel_videos, container, false);
|
||||
|
||||
_query = null
|
||||
_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.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
|
||||
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
|
||||
|
@ -174,6 +225,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
_recyclerResults?.layoutManager = _glmVideo;
|
||||
_recyclerResults?.addOnScrollListener(_scrollListener);
|
||||
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
|
@ -182,6 +234,8 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
_recyclerResults?.removeOnScrollListener(_scrollListener);
|
||||
_recyclerResults = null;
|
||||
_pager = null;
|
||||
_query = null
|
||||
_searchView = null
|
||||
|
||||
_taskLoadVideos.cancel();
|
||||
_nextPageHandler.cancel();
|
||||
|
@ -304,6 +358,7 @@ class ChannelContentsFragment(private val subType: String? = null) : Fragment(),
|
|||
}
|
||||
|
||||
private fun loadInitial() {
|
||||
Logger.i(TAG, "loadInitial")
|
||||
val channel: IPlatformChannel = _channel ?: return;
|
||||
setLoading(true);
|
||||
_taskLoadVideos.run(channel);
|
||||
|
|
|
@ -0,0 +1,785 @@
|
|||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.os.Bundle
|
||||
import android.text.Html
|
||||
import android.text.method.ScrollingMovementMethod
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewPropertyAnimator
|
||||
import android.widget.Button
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.setPadding
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
|
||||
import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails
|
||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
|
||||
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails
|
||||
import com.futo.platformplayer.api.media.models.post.TextType
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSArticleDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSImagesSegment
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSNestedSegment
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSTextSegment
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.SegmentType
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fixHtmlWhitespace
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePlayer
|
||||
import com.futo.platformplayer.states.StatePlaylists
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.toHumanNowDiffString
|
||||
import com.futo.platformplayer.toHumanNumber
|
||||
import com.futo.platformplayer.views.FeedStyle
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewLockedView
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewNestedVideoView
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoView
|
||||
import com.futo.platformplayer.views.comments.AddCommentView
|
||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.overlays.RepliesOverlay
|
||||
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
import com.futo.platformplayer.views.segments.CommentsList
|
||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.futo.polycentric.core.ContentType
|
||||
import com.futo.polycentric.core.Models
|
||||
import com.futo.polycentric.core.Opinion
|
||||
import com.futo.polycentric.core.PolycentricProfile
|
||||
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
|
||||
import com.google.android.flexbox.FlexboxLayout
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
import com.google.android.material.shape.CornerFamily
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import com.google.protobuf.ByteString
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import userpackage.Protocol
|
||||
import java.lang.Integer.min
|
||||
|
||||
class ArticleDetailFragment : MainFragment {
|
||||
override val isMainView: Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var _viewDetail: ArticleDetailView? = null;
|
||||
|
||||
constructor() : super() { }
|
||||
|
||||
override fun onBackPressed(): Boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = ArticleDetailView(inflater.context).applyFragment(this);
|
||||
_viewDetail = view;
|
||||
return view;
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
super.onDestroyMainView();
|
||||
_viewDetail?.onDestroy();
|
||||
_viewDetail = null;
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
|
||||
if (parameter is IPlatformArticleDetails) {
|
||||
_viewDetail?.clear();
|
||||
_viewDetail?.setArticleDetails(parameter);
|
||||
} else if (parameter is IPlatformArticle) {
|
||||
_viewDetail?.setArticleOverview(parameter);
|
||||
} else if(parameter is String) {
|
||||
_viewDetail?.setPostUrl(parameter);
|
||||
}
|
||||
}
|
||||
|
||||
private class ArticleDetailView : ConstraintLayout {
|
||||
private lateinit var _fragment: ArticleDetailFragment;
|
||||
private var _url: String? = null;
|
||||
private var _isLoading = false;
|
||||
private var _article: IPlatformArticleDetails? = null;
|
||||
private var _articleOverview: IPlatformArticle? = null;
|
||||
private var _polycentricProfile: PolycentricProfile? = null;
|
||||
private var _version = 0;
|
||||
private var _isRepliesVisible: Boolean = false;
|
||||
private var _repliesAnimator: ViewPropertyAnimator? = null;
|
||||
|
||||
private val _creatorThumbnail: CreatorThumbnail;
|
||||
private val _buttonSubscribe: SubscribeButton;
|
||||
private val _channelName: TextView;
|
||||
private val _channelMeta: TextView;
|
||||
private val _textTitle: TextView;
|
||||
private val _textMeta: TextView;
|
||||
private val _textSummary: TextView;
|
||||
private val _containerSegments: LinearLayout;
|
||||
private val _platformIndicator: PlatformIndicator;
|
||||
private val _buttonShare: ImageButton;
|
||||
|
||||
private val _layoutRating: LinearLayout;
|
||||
private val _imageLikeIcon: ImageView;
|
||||
private val _textLikes: TextView;
|
||||
private val _imageDislikeIcon: ImageView;
|
||||
private val _textDislikes: TextView;
|
||||
|
||||
private val _addCommentView: AddCommentView;
|
||||
|
||||
private val _rating: PillRatingLikesDislikes;
|
||||
|
||||
private val _layoutLoadingOverlay: FrameLayout;
|
||||
private val _imageLoader: ImageView;
|
||||
|
||||
private var _overlayContainer: FrameLayout
|
||||
private val _repliesOverlay: RepliesOverlay;
|
||||
|
||||
private val _commentsList: CommentsList;
|
||||
|
||||
private var _commentType: Boolean? = null;
|
||||
private val _buttonPolycentric: Button
|
||||
private val _buttonPlatform: Button
|
||||
|
||||
private val _taskLoadPost = if(!isInEditMode) TaskHandler<String, IPlatformArticleDetails>(
|
||||
StateApp.instance.scopeGetter,
|
||||
{
|
||||
val result = StatePlatform.instance.getContentDetails(it).await();
|
||||
if(result !is IPlatformArticleDetails)
|
||||
throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}");
|
||||
return@TaskHandler result;
|
||||
})
|
||||
.success { setArticleDetails(it) }
|
||||
.exception<Throwable> {
|
||||
Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
|
||||
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
|
||||
|
||||
private val _taskLoadPolycentricProfile = TaskHandler<PlatformID, PolycentricProfile?>(StateApp.instance.scopeGetter, {
|
||||
if (!StatePolycentric.instance.enabled)
|
||||
return@TaskHandler null
|
||||
|
||||
ApiMethods.getPolycentricProfileByClaim(ApiMethods.SERVER, ApiMethods.FUTO_TRUST_ROOT, it.claimFieldType.toLong(), it.claimType.toLong(), it.value!!)
|
||||
})
|
||||
.success { it -> setPolycentricProfile(it, animate = true) }
|
||||
.exception<Throwable> {
|
||||
Logger.w(TAG, "Failed to load claims.", it);
|
||||
};
|
||||
|
||||
constructor(context: Context) : super(context) {
|
||||
inflate(context, R.layout.fragview_article_detail, this);
|
||||
|
||||
val root = findViewById<FrameLayout>(R.id.root);
|
||||
|
||||
_creatorThumbnail = findViewById(R.id.creator_thumbnail);
|
||||
_buttonSubscribe = findViewById(R.id.button_subscribe);
|
||||
_channelName = findViewById(R.id.text_channel_name);
|
||||
_channelMeta = findViewById(R.id.text_channel_meta);
|
||||
_textTitle = findViewById(R.id.text_title);
|
||||
_textMeta = findViewById(R.id.text_meta);
|
||||
_textSummary = findViewById(R.id.text_summary);
|
||||
_containerSegments = findViewById(R.id.container_segments);
|
||||
_platformIndicator = findViewById(R.id.platform_indicator);
|
||||
_buttonShare = findViewById(R.id.button_share);
|
||||
|
||||
_overlayContainer = findViewById(R.id.overlay_container);
|
||||
|
||||
_layoutRating = findViewById(R.id.layout_rating);
|
||||
_imageLikeIcon = findViewById(R.id.image_like_icon);
|
||||
_textLikes = findViewById(R.id.text_likes);
|
||||
_imageDislikeIcon = findViewById(R.id.image_dislike_icon);
|
||||
_textDislikes = findViewById(R.id.text_dislikes);
|
||||
|
||||
_commentsList = findViewById(R.id.comments_list);
|
||||
_addCommentView = findViewById(R.id.add_comment_view);
|
||||
|
||||
_rating = findViewById(R.id.rating);
|
||||
|
||||
_layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay);
|
||||
_imageLoader = findViewById(R.id.image_loader);
|
||||
|
||||
|
||||
_repliesOverlay = findViewById(R.id.replies_overlay);
|
||||
|
||||
_buttonPolycentric = findViewById(R.id.button_polycentric)
|
||||
_buttonPlatform = findViewById(R.id.button_platform)
|
||||
|
||||
_buttonSubscribe.onSubscribed.subscribe {
|
||||
//TODO: add overlay to layout
|
||||
//UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
|
||||
};
|
||||
|
||||
val layoutTop: LinearLayout = findViewById(R.id.layout_top);
|
||||
root.removeView(layoutTop);
|
||||
_commentsList.setPrependedView(layoutTop);
|
||||
|
||||
/*TODO: Why is this here?
|
||||
_commentsList.onCommentsLoaded.subscribe {
|
||||
updateCommentType(false);
|
||||
};*/
|
||||
|
||||
_commentsList.onRepliesClick.subscribe { c ->
|
||||
val replyCount = c.replyCount ?: 0;
|
||||
var metadata = "";
|
||||
if (replyCount > 0) {
|
||||
metadata += "$replyCount " + context.getString(R.string.replies);
|
||||
}
|
||||
|
||||
if (c is PolycentricPlatformComment) {
|
||||
var parentComment: PolycentricPlatformComment = c;
|
||||
_repliesOverlay.load(_commentType!!, metadata, c.contextUrl, c.reference, c,
|
||||
{ StatePolycentric.instance.getCommentPager(c.contextUrl, c.reference) },
|
||||
{
|
||||
val newComment = parentComment.cloneWithUpdatedReplyCount((parentComment.replyCount ?: 0) + 1);
|
||||
_commentsList.replaceComment(parentComment, newComment);
|
||||
parentComment = newComment;
|
||||
});
|
||||
} else {
|
||||
_repliesOverlay.load(_commentType!!, metadata, null, null, c, { StatePlatform.instance.getSubComments(c) });
|
||||
}
|
||||
|
||||
setRepliesOverlayVisible(isVisible = true, animate = true);
|
||||
};
|
||||
|
||||
if (StatePolycentric.instance.enabled) {
|
||||
_buttonPolycentric.setOnClickListener {
|
||||
updateCommentType(false)
|
||||
}
|
||||
} else {
|
||||
_buttonPolycentric.visibility = View.GONE
|
||||
}
|
||||
|
||||
_buttonPlatform.setOnClickListener {
|
||||
updateCommentType(true)
|
||||
}
|
||||
|
||||
_addCommentView.onCommentAdded.subscribe {
|
||||
_commentsList.addComment(it);
|
||||
};
|
||||
|
||||
_repliesOverlay.onClose.subscribe { setRepliesOverlayVisible(isVisible = false, animate = true); };
|
||||
|
||||
_buttonShare.setOnClickListener { share() };
|
||||
|
||||
_creatorThumbnail.onClick.subscribe { openChannel() };
|
||||
_channelName.setOnClickListener { openChannel() };
|
||||
_channelMeta.setOnClickListener { openChannel() };
|
||||
}
|
||||
|
||||
private fun openChannel() {
|
||||
val author = _article?.author ?: _articleOverview?.author ?: return;
|
||||
_fragment.navigate<ChannelFragment>(author);
|
||||
}
|
||||
|
||||
private fun share() {
|
||||
try {
|
||||
Logger.i(PreviewPostView.TAG, "sharePost")
|
||||
|
||||
val url = _article?.shareUrl ?: _articleOverview?.shareUrl ?: _url;
|
||||
_fragment.startActivity(Intent.createChooser(Intent().apply {
|
||||
action = Intent.ACTION_SEND;
|
||||
putExtra(Intent.EXTRA_TEXT, url);
|
||||
type = "text/plain"; //TODO: Determine alt types?
|
||||
}, null));
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
Logger.e(PreviewPostView.TAG, "Failed to share.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePolycentricRating() {
|
||||
_rating.visibility = View.GONE;
|
||||
|
||||
val ref = Models.referenceFromBuffer((_article?.url ?: _articleOverview?.url)?.toByteArray() ?: return)
|
||||
val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null }
|
||||
val version = _version;
|
||||
|
||||
_rating.onLikeDislikeUpdated.remove(this);
|
||||
|
||||
if (!StatePolycentric.instance.enabled)
|
||||
return
|
||||
|
||||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
if (version != _version) {
|
||||
return@launch;
|
||||
}
|
||||
|
||||
try {
|
||||
val queryReferencesResponse = ApiMethods.getQueryReferences(ApiMethods.SERVER, ref, null,null,
|
||||
arrayListOf(
|
||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
|
||||
ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.like.data)).build(),
|
||||
Protocol.QueryReferencesRequestCountLWWElementReferences.newBuilder().setFromType(
|
||||
ContentType.OPINION.value).setValue(
|
||||
ByteString.copyFrom(Opinion.dislike.data)).build()
|
||||
),
|
||||
extraByteReferences = listOfNotNull(extraBytesRef)
|
||||
);
|
||||
|
||||
if (version != _version) {
|
||||
return@launch;
|
||||
}
|
||||
|
||||
val likes = queryReferencesResponse.countsList[0];
|
||||
val dislikes = queryReferencesResponse.countsList[1];
|
||||
val hasLiked = StatePolycentric.instance.hasLiked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasLiked(it) } ?: false*/;
|
||||
val hasDisliked = StatePolycentric.instance.hasDisliked(ref.toByteArray())/* || extraBytesRef?.let { StatePolycentric.instance.hasDisliked(it) } ?: false*/;
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (version != _version) {
|
||||
return@withContext;
|
||||
}
|
||||
|
||||
_rating.visibility = VISIBLE;
|
||||
_rating.setRating(RatingLikeDislikes(likes, dislikes), hasLiked, hasDisliked);
|
||||
_rating.onLikeDislikeUpdated.subscribe(this) { args ->
|
||||
if (args.hasLiked) {
|
||||
args.processHandle.opinion(ref, Opinion.like);
|
||||
} else if (args.hasDisliked) {
|
||||
args.processHandle.opinion(ref, Opinion.dislike);
|
||||
} else {
|
||||
args.processHandle.opinion(ref, Opinion.neutral);
|
||||
}
|
||||
|
||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Logger.i(TAG, "Started backfill");
|
||||
args.processHandle.fullyBackfillServersAnnounceExceptions();
|
||||
Logger.i(TAG, "Finished backfill");
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to backfill servers", e)
|
||||
}
|
||||
}
|
||||
|
||||
StatePolycentric.instance.updateLikeMap(ref, args.hasLiked, args.hasDisliked)
|
||||
};
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to get polycentric likes/dislikes.", e);
|
||||
_rating.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPlatformRating(rating: IRating?) {
|
||||
if (rating == null) {
|
||||
_layoutRating.visibility = View.GONE;
|
||||
return;
|
||||
}
|
||||
|
||||
_layoutRating.visibility = View.VISIBLE;
|
||||
|
||||
when (rating) {
|
||||
is RatingLikeDislikes -> {
|
||||
_textLikes.visibility = View.VISIBLE;
|
||||
_imageLikeIcon.visibility = View.VISIBLE;
|
||||
_textLikes.text = rating.likes.toHumanNumber();
|
||||
|
||||
_imageDislikeIcon.visibility = View.VISIBLE;
|
||||
_textDislikes.visibility = View.VISIBLE;
|
||||
_textDislikes.text = rating.dislikes.toHumanNumber();
|
||||
}
|
||||
is RatingLikes -> {
|
||||
_textLikes.visibility = View.VISIBLE;
|
||||
_imageLikeIcon.visibility = View.VISIBLE;
|
||||
_textLikes.text = rating.likes.toHumanNumber();
|
||||
|
||||
_imageDislikeIcon.visibility = View.GONE;
|
||||
_textDislikes.visibility = View.GONE;
|
||||
}
|
||||
else -> {
|
||||
_textLikes.visibility = View.GONE;
|
||||
_imageLikeIcon.visibility = View.GONE;
|
||||
_imageDislikeIcon.visibility = View.GONE;
|
||||
_textDislikes.visibility = View.GONE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun applyFragment(frag: ArticleDetailFragment): ArticleDetailView {
|
||||
_fragment = frag;
|
||||
return this;
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_commentsList.cancel();
|
||||
_taskLoadPost.cancel();
|
||||
_taskLoadPolycentricProfile.cancel();
|
||||
_version++;
|
||||
|
||||
updateCommentType(null)
|
||||
_url = null;
|
||||
_article = null;
|
||||
_articleOverview = null;
|
||||
_creatorThumbnail.clear();
|
||||
//_buttonSubscribe.setSubscribeChannel(null); TODO: clear button
|
||||
_channelName.text = "";
|
||||
setChannelMeta(null);
|
||||
_textTitle.text = "";
|
||||
_textMeta.text = "";
|
||||
setPlatformRating(null);
|
||||
_polycentricProfile = null;
|
||||
_rating.visibility = View.GONE;
|
||||
updatePolycentricRating();
|
||||
setRepliesOverlayVisible(isVisible = false, animate = false);
|
||||
|
||||
_containerSegments.removeAllViews();
|
||||
|
||||
_addCommentView.setContext(null, null);
|
||||
_platformIndicator.clearPlatform();
|
||||
}
|
||||
|
||||
fun setArticleDetails(value: IPlatformArticleDetails) {
|
||||
_url = value.url;
|
||||
_article = value;
|
||||
|
||||
_creatorThumbnail.setThumbnail(value.author.thumbnail, false);
|
||||
_buttonSubscribe.setSubscribeChannel(value.author.url);
|
||||
_channelName.text = value.author.name;
|
||||
setChannelMeta(value);
|
||||
_textTitle.text = value.name;
|
||||
_textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count?
|
||||
|
||||
_textSummary.text = value.summary
|
||||
_textSummary.isVisible = !value.summary.isNullOrEmpty()
|
||||
|
||||
_platformIndicator.setPlatformFromClientID(value.id.pluginId);
|
||||
setPlatformRating(value.rating);
|
||||
|
||||
for(seg in value.segments) {
|
||||
when(seg.type) {
|
||||
SegmentType.TEXT -> {
|
||||
if(seg is JSTextSegment) {
|
||||
_containerSegments.addView(ArticleTextBlock(context, seg.content, seg.textType))
|
||||
}
|
||||
}
|
||||
SegmentType.IMAGES -> {
|
||||
if(seg is JSImagesSegment) {
|
||||
if(seg.images.size > 0)
|
||||
_containerSegments.addView(ArticleImageBlock(context, seg.images[0], seg.caption))
|
||||
}
|
||||
}
|
||||
SegmentType.NESTED -> {
|
||||
if(seg is JSNestedSegment) {
|
||||
_containerSegments.addView(ArticleContentBlock(context, seg.nested, _fragment, _overlayContainer));
|
||||
}
|
||||
}
|
||||
else ->{}
|
||||
}
|
||||
}
|
||||
|
||||
//Fetch only when not already called in setPostOverview
|
||||
if (_articleOverview == null) {
|
||||
fetchPolycentricProfile();
|
||||
updatePolycentricRating();
|
||||
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
|
||||
}
|
||||
|
||||
val commentType = !Settings.instance.other.polycentricEnabled || Settings.instance.comments.defaultCommentSection == 1
|
||||
updateCommentType(commentType, true);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
fun setArticleOverview(value: IPlatformArticle) {
|
||||
clear();
|
||||
_url = value.url;
|
||||
_articleOverview = value;
|
||||
|
||||
_creatorThumbnail.setThumbnail(value.author.thumbnail, false);
|
||||
_buttonSubscribe.setSubscribeChannel(value.author.url);
|
||||
_channelName.text = value.author.name;
|
||||
setChannelMeta(value);
|
||||
_textTitle.text = value.name;
|
||||
_textMeta.text = value.datetime?.toHumanNowDiffString()?.let { "$it ago" } ?: "" //TODO: Include view count?
|
||||
|
||||
_platformIndicator.setPlatformFromClientID(value.id.pluginId);
|
||||
_addCommentView.setContext(value.url, Models.referenceFromBuffer(value.url.toByteArray()));
|
||||
|
||||
updatePolycentricRating();
|
||||
fetchPolycentricProfile();
|
||||
fetchPost();
|
||||
}
|
||||
|
||||
|
||||
private fun setRepliesOverlayVisible(isVisible: Boolean, animate: Boolean) {
|
||||
if (_isRepliesVisible == isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isRepliesVisible = isVisible;
|
||||
_repliesAnimator?.cancel();
|
||||
|
||||
if (isVisible) {
|
||||
_repliesOverlay.visibility = View.VISIBLE;
|
||||
|
||||
if (animate) {
|
||||
_repliesOverlay.translationY = _repliesOverlay.height.toFloat();
|
||||
|
||||
_repliesAnimator = _repliesOverlay.animate()
|
||||
.setDuration(300)
|
||||
.translationY(0f)
|
||||
.withEndAction {
|
||||
_repliesAnimator = null;
|
||||
}.apply { start() };
|
||||
}
|
||||
} else {
|
||||
if (animate) {
|
||||
_repliesOverlay.translationY = 0f;
|
||||
|
||||
_repliesAnimator = _repliesOverlay.animate()
|
||||
.setDuration(300)
|
||||
.translationY(_repliesOverlay.height.toFloat())
|
||||
.withEndAction {
|
||||
_repliesOverlay.visibility = GONE;
|
||||
_repliesAnimator = null;
|
||||
}.apply { start(); }
|
||||
} else {
|
||||
_repliesOverlay.visibility = View.GONE;
|
||||
_repliesOverlay.translationY = _repliesOverlay.height.toFloat();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchPolycentricProfile() {
|
||||
val author = _article?.author ?: _articleOverview?.author ?: return;
|
||||
setPolycentricProfile(null, animate = false);
|
||||
_taskLoadPolycentricProfile.run(author.id);
|
||||
}
|
||||
|
||||
private fun setChannelMeta(value: IPlatformArticle?) {
|
||||
val subscribers = value?.author?.subscribers;
|
||||
if(subscribers != null && subscribers > 0) {
|
||||
_channelMeta.visibility = View.VISIBLE;
|
||||
_channelMeta.text = if((value.author.subscribers ?: 0) > 0) value.author.subscribers!!.toHumanNumber() + " " + context.getString(R.string.subscribers) else "";
|
||||
} else {
|
||||
_channelMeta.visibility = View.GONE;
|
||||
_channelMeta.text = "";
|
||||
}
|
||||
}
|
||||
|
||||
fun setPostUrl(url: String) {
|
||||
clear();
|
||||
_url = url;
|
||||
fetchPost();
|
||||
}
|
||||
|
||||
fun onDestroy() {
|
||||
_commentsList.cancel();
|
||||
_taskLoadPost.cancel();
|
||||
_repliesOverlay.cleanup();
|
||||
}
|
||||
|
||||
private fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) {
|
||||
_polycentricProfile = polycentricProfile;
|
||||
|
||||
val pp = _polycentricProfile;
|
||||
if (pp == null) {
|
||||
_creatorThumbnail.setHarborAvailable(false, animate, null);
|
||||
return;
|
||||
}
|
||||
|
||||
_creatorThumbnail.setHarborAvailable(true, animate, pp.system.toProto());
|
||||
}
|
||||
|
||||
private fun fetchPost() {
|
||||
Logger.i(TAG, "fetchVideo")
|
||||
_article = null;
|
||||
|
||||
val url = _url;
|
||||
if (!url.isNullOrBlank()) {
|
||||
setLoading(true);
|
||||
_taskLoadPost.run(url);
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchComments() {
|
||||
Logger.i(TAG, "fetchComments")
|
||||
_article?.let {
|
||||
_commentsList.load(true) { StatePlatform.instance.getComments(it); };
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchPolycentricComments() {
|
||||
Logger.i(TAG, "fetchPolycentricComments")
|
||||
val post = _article;
|
||||
val ref = (_article?.url ?: _articleOverview?.url)?.toByteArray()?.let { Models.referenceFromBuffer(it) }
|
||||
val extraBytesRef = (_article?.id?.value ?: _articleOverview?.id?.value)?.let { if (it.isNotEmpty()) it.toByteArray() else null }
|
||||
|
||||
if (ref == null) {
|
||||
Logger.w(TAG, "Failed to fetch polycentric comments because url was not set null")
|
||||
_commentsList.clear();
|
||||
return
|
||||
}
|
||||
|
||||
_commentsList.load(false) { StatePolycentric.instance.getCommentPager(post!!.url, ref, listOfNotNull(extraBytesRef)); };
|
||||
}
|
||||
|
||||
private fun updateCommentType(commentType: Boolean?, forceReload: Boolean = false) {
|
||||
val changed = commentType != _commentType
|
||||
_commentType = commentType
|
||||
|
||||
if (commentType == null) {
|
||||
_buttonPlatform.setTextColor(resources.getColor(R.color.gray_ac))
|
||||
_buttonPolycentric.setTextColor(resources.getColor(R.color.gray_ac))
|
||||
} else {
|
||||
_buttonPlatform.setTextColor(resources.getColor(if (commentType) R.color.white else R.color.gray_ac))
|
||||
_buttonPolycentric.setTextColor(resources.getColor(if (!commentType) R.color.white else R.color.gray_ac))
|
||||
|
||||
if (commentType) {
|
||||
_addCommentView.visibility = View.GONE;
|
||||
|
||||
if (forceReload || changed) {
|
||||
fetchComments();
|
||||
}
|
||||
} else {
|
||||
_addCommentView.visibility = View.VISIBLE;
|
||||
|
||||
if (forceReload || changed) {
|
||||
fetchPolycentricComments()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setLoading(isLoading : Boolean) {
|
||||
if (_isLoading == isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isLoading = isLoading;
|
||||
|
||||
if(isLoading) {
|
||||
(_imageLoader.drawable as Animatable?)?.start()
|
||||
_layoutLoadingOverlay.visibility = View.VISIBLE;
|
||||
}
|
||||
else {
|
||||
_layoutLoadingOverlay.visibility = View.GONE;
|
||||
(_imageLoader.drawable as Animatable?)?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
class ArticleTextBlock : LinearLayout {
|
||||
constructor(context: Context?, content: String, textType: TextType) : super(context){
|
||||
inflate(context, R.layout.view_segment_text, this);
|
||||
|
||||
findViewById<TextView>(R.id.text_content)?.let {
|
||||
if(textType == TextType.HTML)
|
||||
it.text = Html.fromHtml(content, Html.FROM_HTML_MODE_COMPACT);
|
||||
else if(textType == TextType.CODE) {
|
||||
it.text = content;
|
||||
it.setPadding(15.dp(resources));
|
||||
it.setHorizontallyScrolling(true);
|
||||
it.movementMethod = ScrollingMovementMethod();
|
||||
it.setTypeface(Typeface.MONOSPACE);
|
||||
it.setBackgroundResource(R.drawable.background_videodetail_description)
|
||||
}
|
||||
else
|
||||
it.text = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
class ArticleImageBlock: LinearLayout {
|
||||
constructor(context: Context?, image: String, caption: String? = null) : super(context){
|
||||
inflate(context, R.layout.view_segment_image, this);
|
||||
|
||||
findViewById<ImageView>(R.id.image_content)?.let {
|
||||
Glide.with(it)
|
||||
.load(image)
|
||||
.crossfade()
|
||||
.into(it);
|
||||
}
|
||||
findViewById<TextView>(R.id.text_content)?.let {
|
||||
if(caption?.isNullOrEmpty() == true)
|
||||
it.isVisible = false;
|
||||
else
|
||||
it.text = caption;
|
||||
}
|
||||
}
|
||||
}
|
||||
class ArticleContentBlock: LinearLayout {
|
||||
constructor(context: Context, content: IPlatformContent?, fragment: ArticleDetailFragment? = null, overlayContainer: FrameLayout? = null): super(context) {
|
||||
if(content != null) {
|
||||
var view: View? = null;
|
||||
if(content is IPlatformNestedContent) {
|
||||
view = PreviewNestedVideoView(context, FeedStyle.THUMBNAIL, null);
|
||||
view.bind(content);
|
||||
view.onContentUrlClicked.subscribe { a,b -> }
|
||||
}
|
||||
else if(content is IPlatformVideo) {
|
||||
view = PreviewVideoView(context, FeedStyle.THUMBNAIL, null, true);
|
||||
view.bind(content);
|
||||
view.onVideoClicked.subscribe { a,b -> fragment?.navigate<VideoDetailFragment>(a) }
|
||||
view.onChannelClicked.subscribe { a -> fragment?.navigate<ChannelFragment>(a) }
|
||||
if(overlayContainer != null) {
|
||||
view.onAddToClicked.subscribe { a -> UISlideOverlays.showVideoOptionsOverlay(a, overlayContainer) };
|
||||
}
|
||||
view.onAddToQueueClicked.subscribe { a -> StatePlayer.instance.addToQueue(a) }
|
||||
view.onAddToWatchLaterClicked.subscribe { a ->
|
||||
if(StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(content), true))
|
||||
UIDialogs.toast("Added to watch later\n[${content.name}]")
|
||||
}
|
||||
}
|
||||
else if(content is IPlatformPost) {
|
||||
view = PreviewPostView(context, FeedStyle.THUMBNAIL);
|
||||
view.bind(content);
|
||||
view.onContentClicked.subscribe { a -> fragment?.navigate<PostDetailFragment>(a) }
|
||||
view.onChannelClicked.subscribe { a -> fragment?.navigate<ChannelFragment>(a) }
|
||||
}
|
||||
else if(content is IPlatformArticle) {
|
||||
view = PreviewPostView(context, FeedStyle.THUMBNAIL);
|
||||
view.bind(content);
|
||||
view.onContentClicked.subscribe { a -> fragment?.navigate<ArticleDetailFragment>(a) }
|
||||
view.onChannelClicked.subscribe { a -> fragment?.navigate<ChannelFragment>(a) }
|
||||
}
|
||||
else if(content is IPlatformLockedContent) {
|
||||
view = PreviewLockedView(context, FeedStyle.THUMBNAIL);
|
||||
view.bind(content);
|
||||
}
|
||||
if(view != null)
|
||||
addView(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
const val TAG = "PostDetailFragment"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = ArticleDetailFragment().apply {}
|
||||
}
|
||||
}
|
|
@ -425,17 +425,15 @@ class ChannelFragment : MainFragment() {
|
|||
_fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
|
||||
buttons.add(Pair(R.drawable.ic_search) {
|
||||
_fragment.navigate<SuggestionsFragment>(
|
||||
SuggestionsFragmentData(
|
||||
"", SearchType.VIDEO, channel.url
|
||||
"", SearchType.VIDEO
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons)
|
||||
}
|
||||
|
||||
if(plugin != null && plugin.capabilities.hasGetChannelCapabilities) {
|
||||
if(plugin.getChannelCapabilities()?.types?.contains(ResultCapabilities.TYPE_SHORTS) ?: false &&
|
||||
!(_viewPager.adapter as ChannelViewPagerAdapter).containsItem(ChannelTab.SHORTS.ordinal.toLong())) {
|
||||
|
|
|
@ -10,12 +10,14 @@ import com.futo.platformplayer.R
|
|||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.UISlideOverlays
|
||||
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
|
||||
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
|
||||
import com.futo.platformplayer.api.media.structures.IPager
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.states.StateMeta
|
||||
|
@ -196,7 +198,14 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
|
|||
fragment.navigate<RemotePlaylistFragment>(content);
|
||||
} else if (content is IPlatformPost) {
|
||||
fragment.navigate<PostDetailFragment>(content);
|
||||
} else if(content is IPlatformArticle) {
|
||||
fragment.navigate<ArticleDetailFragment>(content);
|
||||
}
|
||||
else if(content is JSWeb) {
|
||||
fragment.navigate<WebDetailFragment>(content);
|
||||
}
|
||||
else
|
||||
UIDialogs.appToast("Unknown content type [" + content.contentType.name + "]");
|
||||
}
|
||||
protected open fun onContentUrlClicked(url: String, contentType: ContentType) {
|
||||
when(contentType) {
|
||||
|
|
|
@ -89,7 +89,6 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
private var _sortBy: String? = null;
|
||||
private var _filterValues: HashMap<String, List<String>> = hashMapOf();
|
||||
private var _enabledClientIds: List<String>? = null;
|
||||
private var _channelUrl: String? = null;
|
||||
private var _searchType: SearchType? = null;
|
||||
|
||||
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
|
||||
|
@ -98,10 +97,6 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
|
||||
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { 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)
|
||||
{
|
||||
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)
|
||||
else -> throw Exception("Search type must be specified")
|
||||
}
|
||||
}
|
||||
})
|
||||
.success { loadedResult(it); }.exception<ScriptCaptchaRequiredException> { }
|
||||
.exception<Throwable> {
|
||||
|
@ -147,7 +141,6 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
fun onShown(parameter: Any?) {
|
||||
if(parameter is SuggestionsFragmentData) {
|
||||
setQuery(parameter.query, false);
|
||||
setChannelUrl(parameter.channelUrl, false);
|
||||
setSearchType(parameter.searchType, false)
|
||||
|
||||
fragment.topBar?.apply {
|
||||
|
@ -164,7 +157,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
onFilterClick.subscribe(this) {
|
||||
_overlayContainer.let {
|
||||
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 ->
|
||||
if (changed) {
|
||||
setFilterValues(filtersOverlay.commonCapabilities, filterValuesCopy);
|
||||
|
@ -211,11 +204,7 @@ class ContentSearchResultsFragment : MainFragment() {
|
|||
|
||||
fragment.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val commonCapabilities =
|
||||
if(_channelUrl == null)
|
||||
StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
else
|
||||
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
val commonCapabilities = StatePlatform.instance.getCommonSearchCapabilities(StatePlatform.instance.getEnabledClients().map { it.id });
|
||||
val sorts = commonCapabilities?.sorts ?: listOf();
|
||||
if (sorts.size > 1) {
|
||||
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) {
|
||||
_searchType = searchType
|
||||
|
||||
|
|
|
@ -217,7 +217,7 @@ class PlaylistsFragment : MainFragment() {
|
|||
var playlistsToReturn = pls;
|
||||
if(!_listPlaylistsSearch.text.isNullOrEmpty())
|
||||
playlistsToReturn = playlistsToReturn.filter { it.name.contains(_listPlaylistsSearch.text, true) };
|
||||
if(!_ordering.value.isNullOrEmpty()){
|
||||
if(!_ordering.value.isNullOrEmpty()) {
|
||||
playlistsToReturn = when(_ordering.value){
|
||||
"nameAsc" -> playlistsToReturn.sortedBy { it.name.lowercase() }
|
||||
"nameDesc" -> playlistsToReturn.sortedByDescending { it.name.lowercase() };
|
||||
|
|
|
@ -21,7 +21,7 @@ import com.futo.platformplayer.views.adapters.SearchSuggestionAdapter
|
|||
import com.futo.platformplayer.views.others.RadioGroupView
|
||||
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 {
|
||||
override val isMainView : Boolean = true;
|
||||
|
@ -34,7 +34,6 @@ class SuggestionsFragment : MainFragment {
|
|||
private val _suggestions: ArrayList<String> = ArrayList();
|
||||
private var _query: String? = null;
|
||||
private var _searchType: SearchType = SearchType.VIDEO;
|
||||
private var _channelUrl: String? = null;
|
||||
|
||||
private val _adapterSuggestions = SearchSuggestionAdapter(_suggestions);
|
||||
|
||||
|
@ -52,7 +51,7 @@ class SuggestionsFragment : MainFragment {
|
|||
_adapterSuggestions.onClicked.subscribe { suggestion ->
|
||||
val storage = FragmentedStorage.get<SearchHistoryStorage>();
|
||||
storage.add(suggestion);
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType, _channelUrl));
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(suggestion, _searchType));
|
||||
}
|
||||
_adapterSuggestions.onRemove.subscribe { suggestion ->
|
||||
val index = _suggestions.indexOf(suggestion);
|
||||
|
@ -109,10 +108,8 @@ class SuggestionsFragment : MainFragment {
|
|||
|
||||
if (parameter is SuggestionsFragmentData) {
|
||||
_searchType = parameter.searchType;
|
||||
_channelUrl = parameter.channelUrl;
|
||||
} else if (parameter is SearchType) {
|
||||
_searchType = parameter;
|
||||
_channelUrl = null;
|
||||
}
|
||||
|
||||
_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
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType, _channelUrl));
|
||||
navigate<ContentSearchResultsFragment>(SuggestionsFragmentData(it, _searchType));
|
||||
};
|
||||
|
||||
onTextChange.subscribe(this) {
|
||||
|
|
|
@ -2680,9 +2680,10 @@ class VideoDetailView : ConstraintLayout {
|
|||
}
|
||||
|
||||
onChannelClicked.subscribe {
|
||||
if(it.url.isNotBlank())
|
||||
if(it.url.isNotBlank()) {
|
||||
fragment.minimizeVideoDetail()
|
||||
fragment.navigate<ChannelFragment>(it)
|
||||
else
|
||||
} else
|
||||
UIDialogs.appToast("No author url present");
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,223 @@
|
|||
package com.futo.platformplayer.fragment.mainactivity.main
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewPropertyAnimator
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Button
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.view.children
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumptech.glide.Glide
|
||||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.article.IPlatformArticleDetails
|
||||
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails
|
||||
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
|
||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSWebDetails
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.dp
|
||||
import com.futo.platformplayer.fixHtmlWhitespace
|
||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||
import com.futo.platformplayer.logging.Logger
|
||||
import com.futo.platformplayer.setPlatformPlayerLinkMovementMethod
|
||||
import com.futo.platformplayer.states.StateApp
|
||||
import com.futo.platformplayer.states.StatePlatform
|
||||
import com.futo.platformplayer.states.StatePolycentric
|
||||
import com.futo.platformplayer.toHumanNowDiffString
|
||||
import com.futo.platformplayer.toHumanNumber
|
||||
import com.futo.platformplayer.views.adapters.ChannelTab
|
||||
import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView
|
||||
import com.futo.platformplayer.views.comments.AddCommentView
|
||||
import com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
import com.futo.platformplayer.views.overlays.RepliesOverlay
|
||||
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
|
||||
import com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
import com.futo.platformplayer.views.segments.CommentsList
|
||||
import com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
import com.futo.polycentric.core.ApiMethods
|
||||
import com.futo.polycentric.core.ContentType
|
||||
import com.futo.polycentric.core.Models
|
||||
import com.futo.polycentric.core.Opinion
|
||||
import com.futo.polycentric.core.PolycentricProfile
|
||||
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
|
||||
import com.google.android.flexbox.FlexboxLayout
|
||||
import com.google.android.material.imageview.ShapeableImageView
|
||||
import com.google.android.material.shape.CornerFamily
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import com.google.protobuf.ByteString
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import userpackage.Protocol
|
||||
import java.lang.Integer.min
|
||||
|
||||
class WebDetailFragment : MainFragment {
|
||||
override val isMainView: Boolean = true;
|
||||
override val isTab: Boolean = true;
|
||||
override val hasBottomBar: Boolean get() = true;
|
||||
|
||||
private var _viewDetail: WebDetailView? = null;
|
||||
|
||||
constructor() : super() { }
|
||||
|
||||
override fun onBackPressed(): Boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = WebDetailView(inflater.context).applyFragment(this);
|
||||
_viewDetail = view;
|
||||
return view;
|
||||
}
|
||||
|
||||
override fun onDestroyMainView() {
|
||||
super.onDestroyMainView();
|
||||
_viewDetail?.onDestroy();
|
||||
_viewDetail = null;
|
||||
}
|
||||
|
||||
override fun onShownWithView(parameter: Any?, isBack: Boolean) {
|
||||
super.onShownWithView(parameter, isBack);
|
||||
|
||||
if (parameter is JSWeb) {
|
||||
_viewDetail?.clear();
|
||||
_viewDetail?.setWeb(parameter);
|
||||
}
|
||||
if (parameter is JSWebDetails) {
|
||||
_viewDetail?.clear();
|
||||
_viewDetail?.setWebDetails(parameter);
|
||||
}
|
||||
}
|
||||
|
||||
private class WebDetailView : ConstraintLayout {
|
||||
private lateinit var _fragment: WebDetailFragment;
|
||||
private var _url: String? = null;
|
||||
private var _isLoading = false;
|
||||
private var _web: JSWebDetails? = null;
|
||||
|
||||
private val _layoutLoadingOverlay: FrameLayout;
|
||||
private val _imageLoader: ImageView;
|
||||
|
||||
private val _webview: WebView;
|
||||
|
||||
private val _taskLoadPost = if(!isInEditMode) TaskHandler<String, JSWebDetails>(
|
||||
StateApp.instance.scopeGetter,
|
||||
{
|
||||
val result = StatePlatform.instance.getContentDetails(it).await();
|
||||
if(result !is JSWebDetails)
|
||||
throw IllegalStateException(context.getString(R.string.expected_media_content_found) + " ${result.contentType}");
|
||||
return@TaskHandler result;
|
||||
})
|
||||
.success { setWebDetails(it) }
|
||||
.exception<Throwable> {
|
||||
Logger.w(ChannelFragment.TAG, context.getString(R.string.failed_to_load_post), it);
|
||||
UIDialogs.showGeneralRetryErrorDialog(context, context.getString(R.string.failed_to_load_post), it, ::fetchPost, null, _fragment);
|
||||
} else TaskHandler(IPlatformPostDetails::class.java) { _fragment.lifecycleScope };
|
||||
|
||||
|
||||
constructor(context: Context) : super(context) {
|
||||
inflate(context, R.layout.fragview_web_detail, this);
|
||||
|
||||
val root = findViewById<FrameLayout>(R.id.root);
|
||||
|
||||
_layoutLoadingOverlay = findViewById(R.id.layout_loading_overlay);
|
||||
_imageLoader = findViewById(R.id.image_loader);
|
||||
|
||||
_webview = findViewById(R.id.webview);
|
||||
_webview.webViewClient = object: WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url);
|
||||
if(url != "about:blank")
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun applyFragment(frag: WebDetailFragment): WebDetailView {
|
||||
_fragment = frag;
|
||||
return this;
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_webview.loadUrl("about:blank");
|
||||
}
|
||||
|
||||
fun setWeb(value: JSWeb) {
|
||||
_url = value.url;
|
||||
setLoading(true);
|
||||
clear();
|
||||
fetchPost();
|
||||
}
|
||||
fun setWebDetails(value: JSWebDetails) {
|
||||
_web = value;
|
||||
setLoading(true);
|
||||
_webview.loadUrl("about:blank");
|
||||
if(!value.html.isNullOrEmpty())
|
||||
_webview.loadData(value.html, "text/html", null);
|
||||
else
|
||||
_webview.loadUrl(value.url ?: "about:blank");
|
||||
}
|
||||
|
||||
private fun fetchPost() {
|
||||
Logger.i(WebDetailView.TAG, "fetchWeb")
|
||||
_web = null;
|
||||
|
||||
val url = _url;
|
||||
if (!url.isNullOrBlank()) {
|
||||
setLoading(true);
|
||||
_taskLoadPost.run(url);
|
||||
}
|
||||
}
|
||||
|
||||
fun onDestroy() {
|
||||
_webview.loadUrl("about:blank");
|
||||
}
|
||||
|
||||
private fun setLoading(isLoading : Boolean) {
|
||||
if (_isLoading == isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isLoading = isLoading;
|
||||
|
||||
if(isLoading) {
|
||||
(_imageLoader.drawable as Animatable?)?.start()
|
||||
_layoutLoadingOverlay.visibility = View.VISIBLE;
|
||||
}
|
||||
else {
|
||||
_layoutLoadingOverlay.visibility = View.GONE;
|
||||
(_imageLoader.drawable as Animatable?)?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "WebDetailFragment"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun newInstance() = WebDetailFragment().apply {}
|
||||
}
|
||||
}
|
|
@ -88,7 +88,6 @@ class SearchTopBarFragment : TopFragment() {
|
|||
} else if (parameter is SuggestionsFragmentData) {
|
||||
this.setText(parameter.query);
|
||||
_searchType = parameter.searchType;
|
||||
_channelUrl = parameter.channelUrl;
|
||||
}
|
||||
|
||||
if(currentMain is SuggestionsFragment)
|
||||
|
@ -114,7 +113,7 @@ class SearchTopBarFragment : TopFragment() {
|
|||
fun clear() {
|
||||
_editSearch?.text?.clear();
|
||||
if (currentMain !is SuggestionsFragment) {
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType, _channelUrl), false);
|
||||
navigate<SuggestionsFragment>(SuggestionsFragmentData("", _searchType), false);
|
||||
} else {
|
||||
onSearch.emit("");
|
||||
}
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
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.HLSVariantSubtitleUrlSource
|
||||
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.toYesNo
|
||||
import com.futo.platformplayer.yesNoToBoolean
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.net.URI
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.text.ifEmpty
|
||||
|
||||
class HLS {
|
||||
companion object {
|
||||
@OptIn(UnstableApi::class)
|
||||
fun parseMasterPlaylist(masterPlaylistContent: String, sourceUrl: String): MasterPlaylist {
|
||||
val baseUrl = URI(sourceUrl).resolve("./").toString()
|
||||
|
||||
|
@ -49,6 +58,31 @@ class HLS {
|
|||
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 {
|
||||
val lines = content.lines()
|
||||
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 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>()
|
||||
if (initSegment != null) {
|
||||
segments.add(MediaSegment(0.0, resolveUrl(sourceUrl, initSegment)))
|
||||
}
|
||||
|
||||
var currentSegment: MediaSegment? = null
|
||||
lines.forEach { line ->
|
||||
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> {
|
||||
|
@ -270,7 +322,7 @@ class HLS {
|
|||
val name: String?,
|
||||
val isDefault: Boolean?,
|
||||
val isAutoSelect: Boolean?,
|
||||
val isForced: Boolean?
|
||||
val isForced: Boolean?,
|
||||
) {
|
||||
fun toM3U8Line(): String = buildString {
|
||||
append("#EXT-X-MEDIA:")
|
||||
|
@ -319,30 +371,13 @@ class HLS {
|
|||
|
||||
fun getVideoSources(): List<HLSVariantVideoUrlSource> {
|
||||
return variantPlaylistsRefs.map {
|
||||
var width: Int? = null
|
||||
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)
|
||||
variantReferenceToVariant(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAudioSources(): List<HLSVariantAudioUrlSource> {
|
||||
return mediaRenditions.mapNotNull {
|
||||
if (it.uri == null) {
|
||||
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
|
||||
}
|
||||
return@mapNotNull mediaRenditionToVariant(it)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -368,6 +403,11 @@ class HLS {
|
|||
}
|
||||
}
|
||||
|
||||
data class DecryptionInfo(
|
||||
val keyUrl: String,
|
||||
val iv: String?
|
||||
)
|
||||
|
||||
data class VariantPlaylist(
|
||||
val version: Int?,
|
||||
val targetDuration: Int?,
|
||||
|
@ -376,7 +416,8 @@ class HLS {
|
|||
val programDateTime: ZonedDateTime?,
|
||||
val playlistType: String?,
|
||||
val streamInfo: StreamInfo?,
|
||||
val segments: List<Segment>
|
||||
val segments: List<Segment>,
|
||||
val decryptionInfo: DecryptionInfo? = null
|
||||
) {
|
||||
fun buildM3U8(): String = buildString {
|
||||
append("#EXTM3U\n")
|
||||
|
|
|
@ -412,24 +412,12 @@ class StateApp {
|
|||
}
|
||||
|
||||
if (Settings.instance.synchronization.enabled) {
|
||||
StateSync.instance.start(context, {
|
||||
try {
|
||||
UIDialogs.toast("Failed to start sync, port in use")
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
})
|
||||
StateSync.instance.start(context)
|
||||
}
|
||||
|
||||
settingsActivityClosed.subscribe {
|
||||
if (Settings.instance.synchronization.enabled) {
|
||||
StateSync.instance.start(context, {
|
||||
try {
|
||||
UIDialogs.toast("Failed to start sync, port in use")
|
||||
} catch (e: Throwable) {
|
||||
//Ignored
|
||||
}
|
||||
})
|
||||
StateSync.instance.start(context)
|
||||
} else {
|
||||
StateSync.instance.stop()
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@ import androidx.collection.LruCache
|
|||
import com.futo.platformplayer.R
|
||||
import com.futo.platformplayer.Settings
|
||||
import com.futo.platformplayer.UIDialogs
|
||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||
import com.futo.platformplayer.api.media.IPlatformClient
|
||||
import com.futo.platformplayer.api.media.IPluginSourced
|
||||
import com.futo.platformplayer.api.media.PlatformMultiClientPool
|
||||
|
@ -46,7 +45,6 @@ import com.futo.platformplayer.logging.Logger
|
|||
import com.futo.platformplayer.models.ImageVariable
|
||||
import com.futo.platformplayer.stores.FragmentedStorage
|
||||
import com.futo.platformplayer.stores.StringArrayStorage
|
||||
import com.futo.platformplayer.stores.StringStorage
|
||||
import com.futo.platformplayer.views.ToastView
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
|
@ -56,7 +54,6 @@ import kotlinx.coroutines.cancel
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.internal.concat
|
||||
import java.lang.Thread.sleep
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.streams.asSequence
|
||||
|
@ -669,7 +666,7 @@ class StatePlatform {
|
|||
|
||||
|
||||
//Video
|
||||
fun hasEnabledVideoClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) };
|
||||
fun hasEnabledContentClient(url: String) : Boolean = getEnabledClients().any { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) };
|
||||
fun getContentClient(url: String) : IPlatformClient = getContentClientOrNull(url)
|
||||
?: throw NoPlatformClientException("No client enabled that supports this content url (${url})");
|
||||
fun getContentClientOrNull(url: String) : IPlatformClient? = getEnabledClients().find { _instantClientPool.getClientPooled(it).isContentDetailsUrl(url) };
|
||||
|
|
|
@ -51,7 +51,7 @@ class StateSync {
|
|||
val deviceRemoved: Event1<String> = Event1()
|
||||
val deviceUpdatedOrAdded: Event2<String, SyncSession> = Event2()
|
||||
|
||||
fun start(context: Context, onServerBindFail: () -> Unit) {
|
||||
fun start(context: Context) {
|
||||
if (syncService != null) {
|
||||
Logger.i(TAG, "Already started.")
|
||||
return
|
||||
|
@ -150,24 +150,14 @@ class StateSync {
|
|||
}
|
||||
}
|
||||
|
||||
syncService?.start(context, onServerBindFail)
|
||||
syncService?.start(context)
|
||||
}
|
||||
|
||||
fun showFailedToBindDialogIfNecessary(context: Context) {
|
||||
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) {
|
||||
fun confirmStarted(context: Context, onStarted: () -> Unit, onNotStarted: () -> Unit) {
|
||||
if (syncService == null) {
|
||||
UIDialogs.showConfirmationDialog(context, "Sync has not been enabled yet, would you like to enable sync?", {
|
||||
Settings.instance.synchronization.enabled = true
|
||||
start(context, onServerBindFail)
|
||||
start(context)
|
||||
Settings.instance.save()
|
||||
onStarted.invoke()
|
||||
}, {
|
||||
|
|
|
@ -72,6 +72,8 @@ class SyncService(
|
|||
private val _lastConnectTimesMdns: MutableMap<String, Long> = mutableMapOf()
|
||||
private val _lastConnectTimesIp: MutableMap<String, Long> = mutableMapOf()
|
||||
var serverSocketFailedToStart = false
|
||||
var serverSocketStarted = false
|
||||
var relayConnected = false
|
||||
//TODO: Should sync mdns and casting mdns be merged?
|
||||
//TODO: Decrease interval that devices are updated
|
||||
//TODO: Send less data
|
||||
|
@ -212,7 +214,7 @@ class SyncService(
|
|||
var onData: ((SyncSession, UByte, UByte, ByteBuffer) -> Unit)? = null
|
||||
var authorizePrompt: ((String, (Boolean) -> Unit) -> Unit)? = null
|
||||
|
||||
fun start(context: Context, onServerBindFail: (() -> Unit)? = null) {
|
||||
fun start(context: Context) {
|
||||
if (_started) {
|
||||
Logger.i(TAG, "Already started.")
|
||||
return
|
||||
|
@ -273,10 +275,12 @@ class SyncService(
|
|||
|
||||
Logger.i(TAG, "Sync key pair initialized (public key = $publicKey)")
|
||||
|
||||
serverSocketStarted = false
|
||||
if (settings.bindListener) {
|
||||
startListener(onServerBindFail)
|
||||
startListener()
|
||||
}
|
||||
|
||||
relayConnected = false
|
||||
if (settings.relayEnabled) {
|
||||
startRelayLoop()
|
||||
}
|
||||
|
@ -286,13 +290,15 @@ class SyncService(
|
|||
}
|
||||
}
|
||||
|
||||
private fun startListener(onServerBindFail: (() -> Unit)? = null) {
|
||||
private fun startListener() {
|
||||
serverSocketFailedToStart = false
|
||||
serverSocketStarted = false
|
||||
_thread = Thread {
|
||||
try {
|
||||
val serverSocket = ServerSocket(settings.listenerPort)
|
||||
_serverSocket = serverSocket
|
||||
|
||||
serverSocketStarted = true
|
||||
Log.i(TAG, "Running on port ${settings.listenerPort} (TCP)")
|
||||
|
||||
while (_started) {
|
||||
|
@ -300,10 +306,12 @@ class SyncService(
|
|||
val session = createSocketSession(socket, true)
|
||||
session.startAsResponder()
|
||||
}
|
||||
|
||||
serverSocketStarted = false
|
||||
} catch (e: Throwable) {
|
||||
Logger.e(TAG, "Failed to bind server socket to port ${settings.listenerPort}", e)
|
||||
serverSocketFailedToStart = true
|
||||
onServerBindFail?.invoke()
|
||||
serverSocketStarted = false
|
||||
}
|
||||
}.apply { start() }
|
||||
}
|
||||
|
@ -386,13 +394,16 @@ class SyncService(
|
|||
}
|
||||
|
||||
private fun startRelayLoop() {
|
||||
relayConnected = false
|
||||
_threadRelay = Thread {
|
||||
try {
|
||||
var backoffs: Array<Long> = arrayOf(1000, 5000, 10000, 20000)
|
||||
var backoffIndex = 0;
|
||||
|
||||
while (_started) {
|
||||
try {
|
||||
Log.i(TAG, "Starting relay session...")
|
||||
relayConnected = false
|
||||
|
||||
var socketClosed = false;
|
||||
val socket = Socket(relayServer, 9000)
|
||||
|
@ -400,22 +411,38 @@ class SyncService(
|
|||
(socket.remoteSocketAddress as InetSocketAddress).address.hostAddress!!,
|
||||
keyPair!!,
|
||||
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 ->
|
||||
val remotePublicKey = c.remotePublicKey
|
||||
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
|
||||
}
|
||||
|
||||
Log.i(TAG, "New channel established from relay (pk: '$remotePublicKey').")
|
||||
Log.i(
|
||||
TAG,
|
||||
"New channel established from relay (pk: '$remotePublicKey')."
|
||||
)
|
||||
|
||||
var session: SyncSession?
|
||||
synchronized(_sessions) {
|
||||
session = _sessions[remotePublicKey]
|
||||
if (session == null) {
|
||||
val remoteDeviceName = database.getDeviceName(remotePublicKey)
|
||||
session = createNewSyncSession(remotePublicKey, remoteDeviceName)
|
||||
val remoteDeviceName =
|
||||
database.getDeviceName(remotePublicKey)
|
||||
session =
|
||||
createNewSyncSession(remotePublicKey, remoteDeviceName)
|
||||
_sessions[remotePublicKey] = session!!
|
||||
}
|
||||
session!!.addChannel(c)
|
||||
|
@ -438,23 +465,57 @@ class SyncService(
|
|||
Thread {
|
||||
try {
|
||||
while (_started && !socketClosed) {
|
||||
val unconnectedAuthorizedDevices = database.getAllAuthorizedDevices()?.filter { !isConnected(it) }?.toTypedArray() ?: arrayOf()
|
||||
relaySession.publishConnectionInformation(unconnectedAuthorizedDevices, settings.listenerPort, settings.relayConnectDirect, false, false, settings.relayConnectRelayed)
|
||||
val unconnectedAuthorizedDevices =
|
||||
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")
|
||||
val connectionInfos = runBlocking { relaySession.requestBulkConnectionInfo(unconnectedAuthorizedDevices) }
|
||||
Logger.v(TAG, "Received ${connectionInfos.size} devices connection information")
|
||||
Logger.v(
|
||||
TAG,
|
||||
"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) {
|
||||
val potentialLocalAddresses = connectionInfo.ipv4Addresses
|
||||
val potentialLocalAddresses =
|
||||
connectionInfo.ipv4Addresses
|
||||
.filter { it != connectionInfo.remoteIp }
|
||||
if (connectionInfo.allowLocalDirect && Settings.instance.synchronization.connectLocalDirectThroughRelay) {
|
||||
Thread {
|
||||
try {
|
||||
Log.v(TAG, "Attempting to connect directly, locally to '$targetKey'.")
|
||||
connect(potentialLocalAddresses.map { it }.toTypedArray(), settings.listenerPort, targetKey, null)
|
||||
Log.v(
|
||||
TAG,
|
||||
"Attempting to connect directly, locally to '$targetKey'."
|
||||
)
|
||||
connect(
|
||||
potentialLocalAddresses.map { it }
|
||||
.toTypedArray(),
|
||||
settings.listenerPort,
|
||||
targetKey,
|
||||
null
|
||||
)
|
||||
} 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()
|
||||
}
|
||||
|
@ -469,10 +530,23 @@ class SyncService(
|
|||
|
||||
if (connectionInfo.allowRemoteRelayed && Settings.instance.synchronization.connectThroughRelay) {
|
||||
try {
|
||||
Logger.v(TAG, "Attempting relayed connection with '$targetKey'.")
|
||||
runBlocking { relaySession.startRelayedChannel(targetKey, appId, null) }
|
||||
Logger.v(
|
||||
TAG,
|
||||
"Attempting relayed connection with '$targetKey'."
|
||||
)
|
||||
runBlocking {
|
||||
relaySession.startRelayedChannel(
|
||||
targetKey,
|
||||
appId,
|
||||
null
|
||||
)
|
||||
}
|
||||
} 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
|
||||
}
|
||||
|
||||
relayConnected = true
|
||||
_relaySession!!.runAsInitiator(relayPublicKey, appId, null)
|
||||
|
||||
Log.i(TAG, "Started relay session.")
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Relay session failed.", e)
|
||||
} finally {
|
||||
relayConnected = false
|
||||
_relaySession?.stop()
|
||||
_relaySession = null
|
||||
Thread.sleep(backoffs[min(backoffs.size - 1, backoffIndex++)])
|
||||
}
|
||||
}
|
||||
} catch (ex: Throwable) {
|
||||
Log.i(TAG, "Unhandled exception in relay loop.", ex)
|
||||
}
|
||||
}.apply { start() }
|
||||
}
|
||||
|
||||
|
|
|
@ -529,7 +529,7 @@ class SyncSocketSession {
|
|||
val isAllowed = publicKey != _localPublicKey && (_isHandshakeAllowed?.invoke(LinkType.Relayed, this, publicKey, pairingCode, appId) ?: true)
|
||||
if (!isAllowed) {
|
||||
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.putInt(requestId)
|
||||
rp.rewind()
|
||||
|
|
|
@ -3,6 +3,8 @@ package com.futo.platformplayer.views
|
|||
import android.content.Context
|
||||
import android.text.TextWatcher
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
|
@ -30,9 +32,26 @@ class SearchView : FrameLayout {
|
|||
textSearch = findViewById(R.id.edit_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 {
|
||||
onSearchChanged.emit(it.toString());
|
||||
buttonClear.visibility = if ((it?.length ?: 0) > 0) View.VISIBLE else View.GONE
|
||||
onSearchChanged.emit(it.toString())
|
||||
};
|
||||
}
|
||||
}
|
|
@ -79,6 +79,8 @@ class PreviewContentListAdapter : InsertedViewAdapterWithLoader<ContentPreviewVi
|
|||
return when(contentType) {
|
||||
ContentType.PLACEHOLDER -> createPlaceholderViewHolder(viewGroup);
|
||||
ContentType.MEDIA -> createVideoPreviewViewHolder(viewGroup);
|
||||
ContentType.ARTICLE -> createPostViewHolder(viewGroup);
|
||||
ContentType.WEB -> createPostViewHolder(viewGroup);
|
||||
ContentType.POST -> createPostViewHolder(viewGroup);
|
||||
ContentType.PLAYLIST -> createPlaylistViewHolder(viewGroup);
|
||||
ContentType.NESTED_VIDEO -> createNestedViewHolder(viewGroup);
|
||||
|
|
|
@ -21,9 +21,11 @@ import com.futo.platformplayer.R
|
|||
import com.futo.platformplayer.api.media.PlatformID
|
||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||
import com.futo.platformplayer.api.media.models.article.IPlatformArticle
|
||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPost
|
||||
import com.futo.platformplayer.api.media.models.post.IPlatformPostDetails
|
||||
import com.futo.platformplayer.api.media.platforms.js.models.JSWeb
|
||||
import com.futo.platformplayer.constructs.Event1
|
||||
import com.futo.platformplayer.constructs.TaskHandler
|
||||
import com.futo.platformplayer.dp
|
||||
|
@ -141,6 +143,16 @@ class PreviewPostView : LinearLayout {
|
|||
content.content
|
||||
else
|
||||
""
|
||||
} else if(content is IPlatformArticle) {
|
||||
if(!content.summary.isNullOrEmpty())
|
||||
content.summary ?: ""
|
||||
else
|
||||
""
|
||||
} else if(content is JSWeb) {
|
||||
if(!content.url.isNullOrEmpty())
|
||||
"WEB:" + content.url
|
||||
else
|
||||
""
|
||||
} else "";
|
||||
|
||||
if (content.name.isNullOrEmpty()) {
|
||||
|
@ -154,7 +166,14 @@ class PreviewPostView : LinearLayout {
|
|||
|
||||
if (content is IPlatformPost) {
|
||||
setImages(content.thumbnails.filterNotNull());
|
||||
} else {
|
||||
}
|
||||
else if(content is IPlatformArticle) {
|
||||
if(content.thumbnails != null)
|
||||
setImages(listOf(content.thumbnails!!));
|
||||
else
|
||||
setImages(null);
|
||||
}
|
||||
else {
|
||||
setImages(null);
|
||||
}
|
||||
|
||||
|
|
|
@ -28,17 +28,14 @@ class SlideUpMenuFilters {
|
|||
private var _changed: Boolean = false;
|
||||
private val _lifecycleScope: CoroutineScope;
|
||||
|
||||
private var _isChannelSearch = false;
|
||||
|
||||
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;
|
||||
_container = container;
|
||||
_enabledClientsIds = enabledClientsIds;
|
||||
_filterValues = filterValues;
|
||||
_isChannelSearch = isChannelSearch;
|
||||
_slideUpMenuOverlay = SlideUpMenuOverlay(_container.context, _container, container.context.getString(R.string.filters), container.context.getString(R.string.done), true, listOf());
|
||||
_slideUpMenuOverlay.onOK.subscribe {
|
||||
onOK.emit(_enabledClientsIds, _changed);
|
||||
|
@ -51,10 +48,7 @@ class SlideUpMenuFilters {
|
|||
private fun updateCommonCapabilities() {
|
||||
_lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val caps = if(!_isChannelSearch)
|
||||
StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds);
|
||||
else
|
||||
StatePlatform.instance.getCommonSearchChannelContentsCapabilities(_enabledClientsIds);
|
||||
val caps = StatePlatform.instance.getCommonSearchCapabilities(_enabledClientsIds);
|
||||
synchronized(_filterValues) {
|
||||
if (caps != null) {
|
||||
val keysToRemove = arrayListOf<String>();
|
||||
|
|
|
@ -531,6 +531,8 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
|
|||
fun setLoopVisible(visible: Boolean) {
|
||||
_control_loop.visibility = if (visible) View.VISIBLE else View.GONE;
|
||||
_control_loop_fullscreen.visibility = if (visible) View.VISIBLE else View.GONE;
|
||||
if (StatePlayer.instance.loopVideo && !visible)
|
||||
StatePlayer.instance.loopVideo = false
|
||||
}
|
||||
|
||||
fun stopAllGestures() {
|
||||
|
|
16
app/src/main/res/drawable/ic_launcher_monochrome.xml
Normal file
16
app/src/main/res/drawable/ic_launcher_monochrome.xml
Normal file
|
@ -0,0 +1,16 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="m54,75 l25,-38h-50z"
|
||||
android:strokeWidth=".83"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#000"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m34,64c1,-1.1 3.6,-3.7 5.7,-5 2,-1.3 4.4,-5 5.4,-6.6 2.9,-4.3 9.4,-13 12,-15 0.5,-0.39 1.2,-1 1.5,-1.3 1,-2.3 4.6,-6.3 10,-3.5 0.5,-0.17 1.7,-0.21 2.3,-0.21 -0.44,0.3 -1.4,1.2 -1.5,2.3 -0.45,3.1 -2.1,4.9 -2.9,5.4 -0.56,3.6 -1.3,6.7 -3,7.8l1.5,2.7c2.3,2.4 7.1,7.7 7.8,9.3 -2.2,-0.73 -3.7,-1.4 -4.1,-1.7l4.1,5.7c-2.4,-0.19 -7.9,-1.8 -10,-6.6 0.95,2.6 1.9,5.8 2.2,7.1 -1.3,-1.1 -4.1,-4.2 -4.9,-8 0.22,3.7 0.19,6.4 0.14,7.3 -0.63,-0.58 -2.1,-2.4 -2.9,-5v4.3c-0.94,-1.3 -2.8,-4.5 -3,-6.9 0.16,2.7 0.06,4 -0.01,4.3l-3.6,-3.4c-0.95,0.51 -3.5,1.7 -6.1,2.2 -1.8,1.5 -4,5.3 -4.8,7v-2.2l-2.4,2.4 0.84,-2.5 -1.5,1.3c-0.35,0.21 -1.2,0.63 -1.6,0.63 0.17,-0.39 0.49,-0.82 0.63,-0.98l-2,0.77c0.23,-0.68 0.96,-2.2 2,-2.7 -1.5,0.56 -2,0.7 -2.2,0.7z"
|
||||
android:strokeWidth=".2"/>
|
||||
</vector>
|
|
@ -233,7 +233,7 @@
|
|||
android:isScrollContainer="true"
|
||||
android:scrollbars="vertical"
|
||||
android:maxHeight="200dp"
|
||||
android:text="An error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurredAn error has occurred" />
|
||||
android:text="An error has occurred" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
317
app/src/main/res/layout/fragview_article_detail.xml
Normal file
317
app/src/main/res/layout/fragview_article_detail.xml
Normal file
|
@ -0,0 +1,317 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:fitsSystemWindows="false"
|
||||
android:background="@drawable/bottom_menu_border"
|
||||
android:id="@+id/root"
|
||||
android:clickable="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_top"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:layout_marginLeft="14dp"
|
||||
android:layout_marginRight="14dp"
|
||||
android:layout_marginTop="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_channel_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintRight_toLeftOf="@id/button_subscribe">
|
||||
|
||||
<com.futo.platformplayer.views.others.CreatorThumbnail
|
||||
android:id="@+id/creator_thumbnail"
|
||||
android:layout_width="27dp"
|
||||
android:layout_height="27dp"
|
||||
android:contentDescription="@string/cd_creator_thumbnail" />
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="6dp"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:id="@+id/text_channel_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/white"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginTop="-4dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
tools:text="Channel Name" />
|
||||
<TextView
|
||||
android:id="@+id/text_channel_meta"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#ACACAC"
|
||||
android:textSize="9sp"
|
||||
android:layout_gravity="center"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
tools:text="" />
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<com.futo.platformplayer.views.subscriptions.SubscribeButton
|
||||
android:id="@+id/button_subscribe"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="24 Things I Wish I Had Done Sooner (or my biggest regrets)"
|
||||
android:fontFamily="@font/inter_medium"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="17sp"
|
||||
android:textIsSelectable="true"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginLeft="14dp"
|
||||
android:layout_marginRight="14dp" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="14dp"
|
||||
android:layout_marginRight="14dp"
|
||||
android:layout_marginTop="4dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_meta"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="51K views • 3 years ago"
|
||||
android:fontFamily="@font/inter_regular"
|
||||
android:textColor="@color/gray_ac"
|
||||
android:textSize="10dp"
|
||||
android:layout_gravity="center_vertical"/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_gravity="end|center_vertical"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_rating"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_like_icon"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:contentDescription="@string/cd_image_like_icon"
|
||||
app:srcCompat="@drawable/ic_thumb_up" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_likes"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginStart="8dp"
|
||||
tools:text="500K"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="10dp" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image_dislike_icon"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:contentDescription="@string/cd_image_dislike_icon"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="2dp"
|
||||
app:srcCompat="@drawable/ic_thumb_down" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_dislikes"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="18dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginStart="8dp"
|
||||
tools:text="500K"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="10dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.futo.platformplayer.views.platform.PlatformIndicator
|
||||
android:id="@+id/platform_indicator"
|
||||
android:layout_width="25dp"
|
||||
android:layout_height="25dp"
|
||||
android:scaleType="centerInside"
|
||||
tools:src="@drawable/ic_peertube"
|
||||
android:layout_marginStart="8dp"
|
||||
app:layout_constraintTop_toTopOf="@id/image_author_thumbnail"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="@id/image_author_thumbnail" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_summary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="18dp"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginStart="14dp"
|
||||
android:layout_marginEnd="5dp"
|
||||
android:textFontWeight="400"
|
||||
tools:text="This is the summary of the article"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/container_segments"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginTop="10dp">
|
||||
|
||||
<com.futo.platformplayer.views.pills.PillRatingLikesDislikes
|
||||
android:id="@+id/rating"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="15dp" />
|
||||
|
||||
<Space android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_share"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:contentDescription="@string/cd_button_share"
|
||||
android:background="@drawable/background_button_round"
|
||||
android:gravity="center"
|
||||
android:layout_marginStart="5dp"
|
||||
android:orientation="horizontal"
|
||||
app:srcCompat="@drawable/ic_share"
|
||||
app:tint="@color/white"
|
||||
android:padding="8dp"
|
||||
android:layout_marginEnd="15dp"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:scaleType="fitCenter" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_change_bottom_section"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:background="@drawable/background_videodetail_description"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:layout_marginLeft="14dp"
|
||||
android:layout_marginRight="14dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_polycentric"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:text="Polycentric"
|
||||
android:textColor="#fff"
|
||||
android:textSize="10dp"
|
||||
android:lines="1"
|
||||
android:ellipsize="marquee"
|
||||
android:padding="10dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_platform"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:text="Platform"
|
||||
android:textColor="#fff"
|
||||
android:textSize="10dp"
|
||||
android:lines="1"
|
||||
android:ellipsize="marquee"
|
||||
android:padding="10dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.futo.platformplayer.views.comments.AddCommentView
|
||||
android:id="@+id/add_comment_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:layout_marginStart="28dp"
|
||||
android:layout_marginEnd="28dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.futo.platformplayer.views.segments.CommentsList
|
||||
android:id="@+id/comments_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/layout_loading_overlay"
|
||||
android:visibility="gone"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#77000000"
|
||||
android:elevation="4dp">
|
||||
<ImageView
|
||||
android:id="@+id/image_loader"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="80dp"
|
||||
app:srcCompat="@drawable/ic_loader_animated"
|
||||
android:layout_gravity="top|center_horizontal"
|
||||
android:alpha="0.7"
|
||||
android:layout_marginTop="80dp"
|
||||
android:contentDescription="@string/loading" />
|
||||
</FrameLayout>
|
||||
|
||||
<com.futo.platformplayer.views.overlays.RepliesOverlay
|
||||
android:id="@+id/replies_overlay"
|
||||
android:visibility="gone"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/overlay_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone"
|
||||
android:elevation="15dp">
|
||||
</FrameLayout>
|
||||
</FrameLayout>
|
37
app/src/main/res/layout/fragview_web_detail.xml
Normal file
37
app/src/main/res/layout/fragview_web_detail.xml
Normal file
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:fitsSystemWindows="false"
|
||||
android:background="@drawable/bottom_menu_border"
|
||||
android:id="@+id/root"
|
||||
android:clickable="true">
|
||||
|
||||
<WebView
|
||||
android:id="@+id/webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#111" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/layout_loading_overlay"
|
||||
android:visibility="gone"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#77000000"
|
||||
android:elevation="4dp">
|
||||
<ImageView
|
||||
android:id="@+id/image_loader"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="80dp"
|
||||
app:srcCompat="@drawable/ic_loader_animated"
|
||||
android:layout_gravity="top|center_horizontal"
|
||||
android:alpha="0.7"
|
||||
android:layout_marginTop="80dp"
|
||||
android:contentDescription="@string/loading" />
|
||||
</FrameLayout>
|
||||
|
||||
</FrameLayout>
|
28
app/src/main/res/layout/view_segment_image.xml
Normal file
28
app/src/main/res/layout/view_segment_image.xml
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="7dp"
|
||||
android:paddingEnd="7dp"
|
||||
android:orientation="vertical"
|
||||
android:id="@+id/root">
|
||||
<ImageView
|
||||
android:id="@+id/image_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="10dp"
|
||||
android:layout_marginRight="10dp"
|
||||
android:layout_marginTop="5dp"
|
||||
android:layout_marginBottom="5dp" />
|
||||
<TextView
|
||||
android:id="@+id/text_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAlignment="center"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#999"
|
||||
android:layout_marginBottom="5dp" />
|
||||
</LinearLayout>
|
20
app/src/main/res/layout/view_segment_text.xml
Normal file
20
app/src/main/res/layout/view_segment_text.xml
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:id="@+id/root">
|
||||
<TextView
|
||||
android:id="@+id/text_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12sp"
|
||||
android:layout_weight="400"
|
||||
android:textColor="#BBB"
|
||||
android:layout_marginBottom="5dp"
|
||||
android:layout_marginTop="5dp" />
|
||||
</LinearLayout>
|
|
@ -2,4 +2,5 @@
|
|||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
|
@ -2,4 +2,5 @@
|
|||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
1
app/src/stable/assets/sources/crunchyroll
Submodule
1
app/src/stable/assets/sources/crunchyroll
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit 1aa91f216c0a87604aed1669b63b7830e4288630
|
|
@ -15,7 +15,8 @@
|
|||
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json",
|
||||
"89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json",
|
||||
"8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json",
|
||||
"273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json"
|
||||
"273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json",
|
||||
"9bb33039-8580-48d4-9849-21319ae845a4": "sources/crunchyroll/CrunchyrollConfig.json"
|
||||
},
|
||||
"SOURCES_EMBEDDED_DEFAULT": [
|
||||
"35ae969a-a7db-11ed-afa1-0242ac120002"
|
||||
|
|
1
app/src/unstable/assets/sources/crunchyroll
Submodule
1
app/src/unstable/assets/sources/crunchyroll
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit 1aa91f216c0a87604aed1669b63b7830e4288630
|
|
@ -15,7 +15,8 @@
|
|||
"e8b1ad5f-0c6d-497d-a5fa-0a785a16d902": "sources/bitchute/BitchuteConfig.json",
|
||||
"89ae4889-0420-4d16-ad6c-19c776b28f99": "sources/apple-podcasts/ApplePodcastsConfig.json",
|
||||
"8d029a7f-5507-4e36-8bd8-c19a3b77d383": "sources/tedtalks/TedTalksConfig.json",
|
||||
"273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json"
|
||||
"273b6523-5438-44e2-9f5d-78e0325a8fd9": "sources/curiositystream/CuriosityStreamConfig.json",
|
||||
"9bb33039-8580-48d4-9849-21319ae845a4": "sources/crunchyroll/CrunchyrollConfig.json"
|
||||
},
|
||||
"SOURCES_EMBEDDED_DEFAULT": [
|
||||
"35ae969a-a7db-11ed-afa1-0242ac120002"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue