Compare commits

..

108 Commits

Author SHA1 Message Date
Kelvin eb3dd854d4 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-23 17:28:23 +01:00
Kelvin c529446219 Attempt to fetch live videos for offline videos 2023-11-23 17:28:14 +01:00
Koen fa2f8c3447 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-23 16:45:09 +01:00
Koen 840d1ae534 Fixes to adhere closer to the HLS spec and Twitch VODs no longer start at end. 2023-11-23 16:44:58 +01:00
Kelvin 2530c6eb58 Live chat improvements and fixes 2023-11-23 16:35:13 +01:00
Koen ee3761c780 Added full support for HLS casting to Airplay. 2023-11-23 13:18:09 +01:00
Koen e4c89e9aa9 Extended HLS spec, fixes to YES NO booleans, started on implementing HLS stream combiner. 2023-11-23 12:48:16 +01:00
Koen 9d5888ddf7 Fixed VODs not working properly for YouTube and Twitch. 2023-11-23 11:48:50 +01:00
Koen ecc94920d7 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-22 22:33:05 +01:00
Koen 5cafbf243e Fixed channel contents long press and fixed a crash due to time bars. 2023-11-22 22:32:44 +01:00
Kelvin f3fa208680 Kick subs fix, dedup fix 2023-11-22 18:04:29 +01:00
Kelvin 502602e27a Reordering progress bar settings 2023-11-22 16:50:54 +01:00
Kelvin 5054b093a4 Stable refs 2023-11-22 16:15:05 +01:00
Kelvin 0ffaec6bc2 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-22 16:05:39 +01:00
Kelvin ef8ea9eecf Fix whitelist checking for dev-portal 2023-11-22 16:05:27 +01:00
Koen b09d22e479 Added historical time bars to videos. 2023-11-22 14:49:34 +01:00
Koen 01787b6229 Added backfill exception printing to announcements. 2023-11-22 12:46:39 +01:00
Koen 4c022698d3 Quality selection overlay now properly closes when pressing the back button. 2023-11-22 11:32:51 +01:00
Koen bfdcab0e84 Properly handle V1 encrypted secrets in the upgrade process from V0 to V1. 2023-11-22 11:21:18 +01:00
Koen aaea5cc963 Only close the app on closeSegment if there is no video playing. 2023-11-22 10:38:04 +01:00
Koen 23d9c33406 Added support for v6 Odysee URLs. 2023-11-22 10:27:35 +01:00
Koen fad1b216df Further extended HLS spec that is implemented. 2023-11-22 09:32:52 +01:00
Kelvin e221b508d3 Improved notifications, experimental scheduled notifications 2023-11-21 23:31:26 +01:00
Koen dfafac7d99 Merge branch 'hls-live-stream-proxy' into 'master'
Finished implementation of HLS proxying.

See merge request videostreaming/grayjay!5
2023-11-21 15:12:09 +00:00
Koen 2246f8cee2 Finished implementation of HLS proxying. 2023-11-21 15:12:09 +00:00
Koen 8661ff88c0 Another iteration on the HttpContext fix. 2023-11-20 12:14:03 +01:00
Koen 0bba7fa373 Keep screen on fixes. 2023-11-17 16:44:16 +01:00
Kelvin 0c1822b118 Locked content support 2023-11-17 00:34:21 +01:00
Kelvin 6df8f84421 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-16 17:13:50 +01:00
Kelvin 7fa80ec048 Fix communication issues devportal 2023-11-16 17:13:23 +01:00
Koen b3f9b81984 Do not allow empty polycentric comments. 2023-11-16 15:51:34 +01:00
Kelvin 1393c489c1 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-15 20:02:36 +01:00
Kelvin 640c2cbed0 Chapter accuracy now sub-second 2023-11-15 20:02:28 +01:00
Koen e55509f549 Merge branch 'casting-fixes' into 'master'
Content length is now set correctly for HttpConstantHandler.

See merge request videostreaming/grayjay!4
2023-11-15 16:18:38 +00:00
Koen 27c7fb0c12 Content length is now set correctly for HttpConstantHandler. 2023-11-15 16:18:38 +00:00
Koen 88f3815585 When clicking on a video it is added to queue instead of replacing queue. 2023-11-15 11:49:22 +01:00
Koen 2e9405cfdb Exit full screen swipe is now a down gesture. 2023-11-15 11:40:09 +01:00
Koen 9c1b543ed6 Added 'Add to new playlist' button in options menu. 2023-11-14 14:58:24 +01:00
Koen d34cb0f9c1 Added support for long-press gesture to open options menu. 2023-11-14 14:42:41 +01:00
Koen 116dc90d21 Changed the way the changelog is written. 2023-11-14 14:18:17 +01:00
Kelvin 17b9853bb6 Better subscription behavior reporting 2023-11-14 00:27:13 +01:00
Kelvin 8bfb8abd20 Fix notifications 2023-11-13 23:35:25 +01:00
Kelvin 9ee3f1f26e Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-13 22:10:43 +01:00
Kelvin 5dcff29d8d Language on all activities, Fix plugin initial state, Allow rotation prevention bypass, Settings warning support 2023-11-13 22:10:31 +01:00
Koen 6cfbd0c8bf Added + Tax indicator 2023-11-13 15:58:01 +01:00
Koen 01d96cce16 Made export request folder to export to. 2023-11-13 15:49:55 +01:00
Koen 58c376f011 Fixed Rumble subscription import. 2023-11-13 12:06:46 +01:00
Kelvin 439d339330 Fix channel membership reset 2023-11-11 16:47:14 +01:00
Kelvin 44eacc2a47 Stable refs 2023-11-10 20:38:46 +01:00
Kelvin 8135d61398 Fix language issue 2023-11-10 20:34:43 +01:00
Kelvin 66208f8265 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-10 19:49:20 +01:00
Kelvin f52251e23a Hide creators, Fix hide video, 2023-11-10 19:49:09 +01:00
Koen dbea93efe5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-10 12:10:30 +01:00
Koen 3bf0740bd1 Fixed control cast on non-fullscreen. 2023-11-10 12:10:12 +01:00
Kelvin fa7f1b11f3 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-09 19:49:51 +01:00
Kelvin ff914bbdf4 Temporary subscription workarounds 2023-11-09 19:49:45 +01:00
Koen b822078d4b Fixed support tab falsely showing when a creator does not have a polycentric profile. 2023-11-09 19:12:54 +01:00
Kelvin 290d2ceb50 Polycentrif ref 2023-11-09 16:57:32 +01:00
Kelvin 8ec9025990 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-09 16:55:32 +01:00
Kelvin c4cf856dcd Stable submods 2023-11-09 16:55:26 +01:00
Koen 38bb4e25d3 Merge branch 'encryption-changes' into 'master'
Encryption changes, load more on import subscriptions.

See merge request videostreaming/grayjay!3
2023-11-09 15:04:55 +00:00
Koen 0de996d91c Encryption changes, load more on import subscriptions. 2023-11-09 15:04:54 +00:00
Kelvin 1f38c9b27d Logs 2023-11-09 15:46:22 +01:00
Kelvin 234f31b02d Logs and submods 2023-11-09 15:42:33 +01:00
Koen 00e40e8cd6 Merge branch 'encryption-provider-split' into 'master'
Encryption provider split.

See merge request videostreaming/grayjay!2
2023-11-08 15:06:29 +00:00
Koen 0bc6a43dc1 Encryption provider split. 2023-11-08 15:06:29 +00:00
Kelvin e7e0157fbc Stable refs 2023-11-08 16:06:07 +01:00
Kelvin 4cae1a41a5 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-08 16:03:01 +01:00
Kelvin 4fa61e7f52 Additional plugin settings capabilities 2023-11-08 16:02:52 +01:00
Koen f02ac796f5 Updated YT stable. 2023-11-08 13:23:49 +01:00
Kelvin 22146a6bdc Playlists ui tweaks 2023-11-08 12:15:53 +01:00
Kelvin 5285eae01d Channel membership support and live chat donation support fix 2023-11-07 20:34:23 +01:00
Koen c47ca369e4 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-07 17:16:19 +01:00
Koen f0b1f62bb1 Casting button no longer visible when disabled (may require restart). 2023-11-07 17:16:06 +01:00
Kelvin f7aa6d006e Add header to login activity with current url 2023-11-07 16:46:55 +01:00
Kelvin 6b67cd549f Fix chapters not getting cleared 2023-11-07 16:24:22 +01:00
Kelvin fc6bf85822 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-07 16:04:26 +01:00
Kelvin fbd9345cf8 Fix fallback to cache results 2023-11-07 16:04:19 +01:00
Koen 63137b4c4d Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-07 16:03:05 +01:00
Koen e28dc7a3a6 Crash fix for bottom menu bar for specific screen dimensions. 2023-11-07 16:02:55 +01:00
Kelvin 6e14acc685 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-07 15:43:21 +01:00
Kelvin ba64153f1d Language setting, Preview setting, Background audio switch setting, No error on comments failed 2023-11-07 15:43:13 +01:00
Koen 72c04e7556 Added casting button in full screen. 2023-11-07 15:08:33 +01:00
Koen 54f37ee5b2 Fixed crash. 2023-11-07 15:01:22 +01:00
Koen 4fbb325313 Added fix for video player not filling height properly for audio only in a playlist. 2023-11-07 14:20:57 +01:00
Koen e1d3b95f73 Fixed crash on Android 9 when playing a video. 2023-11-07 13:35:13 +01:00
Koen 8f7b4b8257 Merge branch 'monetization' into 'master'
Monetization

See merge request videostreaming/grayjay!1
2023-11-07 12:10:40 +00:00
Koen 9d906025ea Monetization 2023-11-07 12:10:40 +00:00
Kelvin d7f4dd65e8 Stable refs 2023-11-06 14:58:11 +01:00
Kelvin 599b119e62 Remove plugin interaction on main thread for channels 2023-11-06 14:53:24 +01:00
Kelvin 41176464db Fix missing swipe to refresh on tab switch 2023-11-06 14:43:24 +01:00
Kelvin dd0ad19fb9 NewLine subs import, fix no-recent video subscriptions 2023-11-06 14:25:09 +01:00
Kelvin 430625d2fb Fix icon colors 2023-11-06 13:37:18 +01:00
Kelvin 796cd1a776 Merge branch 'master' of gitlab.futo.org:videostreaming/grayjay 2023-11-06 13:20:42 +01:00
Kelvin baa26af0c0 Only show sub toasts when on subs page, WIP import ui 2023-11-06 13:20:33 +01:00
Koen ea0c27936e Fixed videos not automatically going to next video in playlist when casting. 2023-11-05 15:13:57 +01:00
Kelvin 4aade35d19 Grayjay schema channel support 2023-11-04 18:42:04 +01:00
Kelvin 251a5701af Custom grayjay open video url handling 2023-11-04 18:31:01 +01:00
Kelvin 2da3116111 Fix initial selection of subscription settings 2023-11-03 20:07:08 +01:00
Kelvin 4c82fa1a4a Stable refs 2023-11-03 18:25:40 +01:00
Kelvin 7eef6eece2 Primary claim support, fix sub for clients without type 2023-11-03 18:17:04 +01:00
Kelvin 570f32e980 PlatformUrl support 2023-11-03 15:39:27 +01:00
Kelvin 16a0351125 Per-plugin ratelimit setting 2023-11-03 15:15:18 +01:00
Kelvin 2fa9005806 Keep plugin settings on update 2023-11-03 14:46:43 +01:00
Kelvin 25527997fa Fix channels updating while they shouldnt 2023-11-03 14:37:36 +01:00
Kelvin 4655d8369d Reduce subscription calls, Improve subs sorting, Improve view sorting 2023-11-03 13:34:23 +01:00
Kelvin aeaaace3a4 Subscription settings from creators tab 2023-11-02 23:42:51 +01:00
Kelvin e6997004ff Fix new user crash, show/hide subscription settings button on change, raise import limit to 90 2023-11-02 23:22:42 +01:00
235 changed files with 6238 additions and 1108 deletions
@@ -1,13 +1,14 @@
package com.futo.platformplayer package com.futo.platformplayer
import com.futo.platformplayer.encryption.EncryptionProvider import com.futo.platformplayer.encryption.GEncryptionProviderV0
import com.futo.platformplayer.encryption.GEncryptionProviderV1
import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertEquals
import org.junit.Test import org.junit.Test
class EncryptionProviderTests { class GEncryptionProviderTests {
@Test @Test
fun testEncryptDecrypt() { fun testEncryptDecryptV1() {
val encryptionProvider = EncryptionProvider.instance val encryptionProvider = GEncryptionProviderV1.instance
val plaintext = "This is a test string." val plaintext = "This is a test string."
// Encrypt the plaintext // Encrypt the plaintext
@@ -22,8 +23,8 @@ class EncryptionProviderTests {
@Test @Test
fun testEncryptDecryptBytes() { fun testEncryptDecryptBytesV1() {
val encryptionProvider = EncryptionProvider.instance val encryptionProvider = GEncryptionProviderV1.instance
val bytes = "This is a test string.".toByteArray(); val bytes = "This is a test string.".toByteArray();
// Encrypt the plaintext // Encrypt the plaintext
@@ -36,21 +37,36 @@ class EncryptionProviderTests {
assertArrayEquals(bytes, decrypted); assertArrayEquals(bytes, decrypted);
} }
@Test @Test
fun testEncryptDecryptBytesPassword() { fun testEncryptDecryptV0() {
val encryptionProvider = EncryptionProvider.instance val encryptionProvider = GEncryptionProviderV0.instance
val bytes = "This is a test string.".toByteArray(); val plaintext = "This is a test string."
val password = "1234".padStart(32, '9');
// Encrypt the plaintext // Encrypt the plaintext
val ciphertext = encryptionProvider.encrypt(bytes, password) val ciphertext = encryptionProvider.encrypt(plaintext)
// Decrypt the ciphertext // Decrypt the ciphertext
val decrypted = encryptionProvider.decrypt(ciphertext, password) val decrypted = encryptionProvider.decrypt(ciphertext)
// The decrypted string should be equal to the original plaintext
assertEquals(plaintext, decrypted)
}
@Test
fun testEncryptDecryptBytesV0() {
val encryptionProvider = GEncryptionProviderV0.instance
val bytes = "This is a test string.".toByteArray();
// Encrypt the plaintext
val ciphertext = encryptionProvider.encrypt(bytes)
// Decrypt the ciphertext
val decrypted = encryptionProvider.decrypt(ciphertext)
// The decrypted string should be equal to the original plaintext // The decrypted string should be equal to the original plaintext
assertArrayEquals(bytes, decrypted); assertArrayEquals(bytes, decrypted);
} }
private fun assertArrayEquals(a: ByteArray, b: ByteArray) { private fun assertArrayEquals(a: ByteArray, b: ByteArray) {
@@ -0,0 +1,45 @@
package com.futo.platformplayer
import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV0
import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV1
import junit.framework.TestCase.assertEquals
import org.junit.Test
class GPasswordEncryptionProviderTests {
@Test
fun testEncryptDecryptBytesPasswordV1() {
val encryptionProvider = GPasswordEncryptionProviderV1();
val bytes = "This is a test string.".toByteArray();
// Encrypt the plaintext
val ciphertext = encryptionProvider.encrypt(bytes, "1234")
// Decrypt the ciphertext
val decrypted = encryptionProvider.decrypt(ciphertext, "1234")
// The decrypted string should be equal to the original plaintext
assertArrayEquals(bytes, decrypted);
}
@Test
fun testEncryptDecryptBytesPasswordV0() {
val encryptionProvider = GPasswordEncryptionProviderV0("1234".padStart(32, '9'));
val bytes = "This is a test string.".toByteArray();
// Encrypt the plaintext
val ciphertext = encryptionProvider.encrypt(bytes)
// Decrypt the ciphertext
val decrypted = encryptionProvider.decrypt(ciphertext)
// The decrypted string should be equal to the original plaintext
assertArrayEquals(bytes, decrypted);
}
private fun assertArrayEquals(a: ByteArray, b: ByteArray) {
assertEquals(a.size, b.size);
for(i in 0 until a.size) {
assertEquals(a[i], b[i]);
}
}
}
+23
View File
@@ -9,6 +9,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="com.android.alarm.permission.SET_ALARM"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -39,6 +41,7 @@
<receiver android:name=".receivers.MediaControlReceiver" /> <receiver android:name=".receivers.MediaControlReceiver" />
<receiver android:name=".receivers.AudioNoisyReceiver" /> <receiver android:name=".receivers.AudioNoisyReceiver" />
<receiver android:name=".receivers.PlannedNotificationReceiver" />
<activity <activity
android:name=".activities.MainActivity" android:name=".activities.MainActivity"
@@ -92,6 +95,26 @@
<data android:host="*" /> <data android:host="*" />
<data android:scheme="file" /> <data android:scheme="file" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:autoVerify="true">
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<data android:host="*" />
<data android:scheme="content" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter android:autoVerify="true">
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.VIEW" />
<data android:host="*" />
<data android:scheme="file" />
<data android:mimeType="application/zip" /> <data android:mimeType="application/zip" />
</intent-filter> </intent-filter>
<intent-filter android:autoVerify="true"> <intent-filter android:autoVerify="true">
+5 -2
View File
@@ -540,6 +540,8 @@
<script> <script>
IS_TESTING = true; IS_TESTING = true;
let lastScriptTag = null; let lastScriptTag = null;
let shouldDevLog = true;
let shouldLoginCheck = true;
new Vue({ new Vue({
el: '#app', el: '#app',
data: { data: {
@@ -603,7 +605,7 @@
}; };
setInterval(()=>{ setInterval(()=>{
try{ try{
if(!this.Plugin.currentPlugin) if(!this.Plugin.currentPlugin || !shouldDevLog)
return; return;
getDevLogs(this.Integration.lastLogIndex, (newLogs)=> { getDevLogs(this.Integration.lastLogIndex, (newLogs)=> {
@@ -638,7 +640,8 @@
}, 1000); }, 1000);
setInterval(()=>{ setInterval(()=>{
try{ try{
this.isTestLoggedIn(); if(shouldLoginCheck)
this.isTestLoggedIn();
}catch(ex){} }catch(ex){}
}, 2500); }, 2500);
}, },
+27 -2
View File
@@ -10,7 +10,8 @@ let Type = {
Videos: "VIDEOS", Videos: "VIDEOS",
Streams: "STREAMS", Streams: "STREAMS",
Mixed: "MIXED", Mixed: "MIXED",
Live: "LIVE" Live: "LIVE",
Subscriptions: "SUBSCRIPTIONS"
}, },
Order: { Order: {
Chronological: "CHRONOLOGICAL" Chronological: "CHRONOLOGICAL"
@@ -159,13 +160,27 @@ class FilterCapability {
class PlatformAuthorLink { class PlatformAuthorLink {
constructor(id, name, url, thumbnail, subscribers) { constructor(id, name, url, thumbnail, subscribers, membershipUrl) {
this.id = id ?? PlatformID(); //PlatformID this.id = id ?? PlatformID(); //PlatformID
this.name = name ?? ""; //string this.name = name ?? ""; //string
this.url = url ?? ""; //string this.url = url ?? ""; //string
this.thumbnail = thumbnail; //string this.thumbnail = thumbnail; //string
if(subscribers) if(subscribers)
this.subscribers = subscribers; this.subscribers = subscribers;
if(membershipUrl)
this.membershipUrl = membershipUrl ?? null; //string (for backcompat)
}
}
class PlatformAuthorMembershipLink {
constructor(id, name, url, thumbnail, subscribers, membershipUrl) {
this.id = id ?? PlatformID(); //PlatformID
this.name = name ?? ""; //string
this.url = url ?? ""; //string
this.thumbnail = thumbnail; //string
if(subscribers)
this.subscribers = subscribers;
if(membershipUrl)
this.membershipUrl = membershipUrl ?? null; //string
} }
} }
class PlatformContent { class PlatformContent {
@@ -196,6 +211,16 @@ class PlatformNestedMediaContent extends PlatformContent {
this.contentThumbnails = obj.contentThumbnails ?? new Thumbnails(); this.contentThumbnails = obj.contentThumbnails ?? new Thumbnails();
} }
} }
class PlatformLockedContent extends PlatformContent {
constructor(obj) {
super(obj, 70);
obj = obj ?? {};
this.contentName = obj.contentName;
this.contentThumbnails = obj.contentThumbnails ?? new Thumbnails();
this.unlockUrl = obj.unlockUrl ?? "";
this.lockDescription = obj.lockDescription;
}
}
class PlatformVideo extends PlatformContent { class PlatformVideo extends PlatformContent {
constructor(obj) { constructor(obj) {
super(obj, 1); super(obj, 1);
@@ -1,11 +1,15 @@
package com.futo.platformplayer package com.futo.platformplayer
import com.google.common.base.CharMatcher import com.google.common.base.CharMatcher
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.net.Inet4Address import java.net.Inet4Address
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.charset.Charset
private const val IPV4_PART_COUNT = 4; private const val IPV4_PART_COUNT = 4;
@@ -273,3 +277,46 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
return connectedSocket; return connectedSocket;
} }
fun InputStream.readHttpHeaderBytes() : ByteArray {
val headerBytes = ByteArrayOutputStream()
var crlfCount = 0
while (crlfCount < 4) {
val b = read()
if (b == -1) {
throw IOException("Unexpected end of stream while reading headers")
}
if (b == 0x0D || b == 0x0A) { // CR or LF
crlfCount++
} else {
crlfCount = 0
}
headerBytes.write(b)
}
return headerBytes.toByteArray()
}
fun InputStream.readLine() : String? {
val line = ByteArrayOutputStream()
var crlfCount = 0
while (crlfCount < 2) {
val b = read()
if (b == -1) {
return null
}
if (b == 0x0D || b == 0x0A) { // CR or LF
crlfCount++
} else {
crlfCount = 0
line.write(b)
}
}
return String(line.toByteArray(), Charsets.UTF_8)
}
@@ -1,6 +1,11 @@
package com.futo.platformplayer package com.futo.platformplayer
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.AnnouncementType
import com.futo.platformplayer.states.StateAnnouncement
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.views.adapters.CommentViewHolder
import com.futo.polycentric.core.ProcessHandle
import userpackage.Protocol import userpackage.Protocol
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.min import kotlin.math.min
@@ -39,4 +44,21 @@ fun Protocol.Claim.resolveChannelUrl(): String? {
fun Protocol.Claim.resolveChannelUrls(): List<String> { fun Protocol.Claim.resolveChannelUrls(): List<String> {
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) }) return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
}
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
val exceptions = fullyBackfillServers()
for (pair in exceptions) {
val server = pair.key
val exception = pair.value
StateAnnouncement.instance.registerAnnouncement(
"backfill-failed",
"Backfill failed",
"Failed to backfill server $server. $exception",
AnnouncementType.SESSION_RECURRING
);
Logger.e("Backfill", "Failed to backfill server $server.", exception)
}
} }
@@ -1,5 +1,10 @@
package com.futo.platformplayer package com.futo.platformplayer
import android.net.Uri
import java.net.URI
import java.net.URISyntaxException
import java.net.URLEncoder
//Syntax sugaring //Syntax sugaring
inline fun <reified T> Any.assume(): T?{ inline fun <reified T> Any.assume(): T?{
if(this is T) if(this is T)
@@ -12,4 +17,12 @@ inline fun <reified T, R> Any.assume(cb: (T) -> R): R? {
if(result != null) if(result != null)
return cb(result); return cb(result);
return null; return null;
}
fun String?.yesNoToBoolean(): Boolean {
return this?.uppercase() == "YES"
}
fun Boolean?.toYesNo(): String {
return if (this == true) "YES" else "NO"
} }
@@ -109,11 +109,29 @@ inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextN
else else
return this.expectOrThrow<V8ValueLong>(config, contextName).value.toLong() as T return this.expectOrThrow<V8ValueLong>(config, contextName).value.toLong() as T
}; };
Float::class -> {
if(this is V8ValueDouble)
return this.value.toFloat() as T;
else if(this is V8ValueInteger)
return this.value.toFloat() as T;
else if(this is V8ValueLong)
return this.value.toFloat() as T;
else
return this.expectOrThrow<V8ValueDouble>(config, contextName).value.toDouble() as T
};
Double::class -> {
if(this is V8ValueDouble)
return this.value.toDouble() as T;
else if(this is V8ValueInteger)
return this.value.toDouble() as T;
else if(this is V8ValueLong)
return this.value.toDouble() as T;
else
return this.expectOrThrow<V8ValueDouble>(config, contextName).value.toDouble() as T
};
V8ValueObject::class -> this.expectOrThrow<V8ValueObject>(config, contextName) as T V8ValueObject::class -> this.expectOrThrow<V8ValueObject>(config, contextName) as T
V8ValueArray::class -> this.expectOrThrow<V8ValueArray>(config, contextName) as T; V8ValueArray::class -> this.expectOrThrow<V8ValueArray>(config, contextName) as T;
Boolean::class -> this.expectOrThrow<V8ValueBoolean>(config, contextName).value as T; Boolean::class -> this.expectOrThrow<V8ValueBoolean>(config, contextName).value as T;
Float::class -> this.expectOrThrow<V8ValueDouble>(config, contextName).value.toFloat() as T;
Double::class -> this.expectOrThrow<V8ValueDouble>(config, contextName).value as T;
HashMap::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T; HashMap::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T;
Map::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T; Map::class -> this.expectOrThrow<V8ValueObject>(config, contextName).let { V8ObjectToHashMap(it) } as T;
List::class -> this.expectOrThrow<V8ValueArray>(config, contextName).let { V8ArrayToStringList(it) } as T; List::class -> this.expectOrThrow<V8ValueArray>(config, contextName).let { V8ArrayToStringList(it) } as T;
@@ -0,0 +1,20 @@
package com.futo.platformplayer
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
class HorizontalSpaceItemDecoration(private val startSpace: Int, private val betweenSpace: Int, private val endSpace: Int) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
outRect.left = betweenSpace
val position = parent.getChildAdapterPosition(view)
if (position == 0) {
outRect.left = startSpace
}
else if (position == state.itemCount - 1) {
outRect.right = endSpace
}
}
}
@@ -4,12 +4,11 @@ import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.webkit.CookieManager import android.webkit.CookieManager
import androidx.core.content.ContextCompat.startActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.activities.* import com.futo.platformplayer.activities.*
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
@@ -23,6 +22,7 @@ import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
import com.futo.platformplayer.views.fields.FormField import com.futo.platformplayer.views.fields.FormField
import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormFieldButton import com.futo.platformplayer.views.fields.FormFieldButton
import com.futo.platformplayer.views.fields.FormFieldWarning
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -30,6 +30,7 @@ import kotlinx.serialization.*
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import java.io.File import java.io.File
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.Locale
@Serializable @Serializable
data class MenuBottomBarSetting(val id: Int, var enabled: Boolean); data class MenuBottomBarSetting(val id: Int, var enabled: Boolean);
@@ -44,10 +45,7 @@ class Settings : FragmentedStorageFileJson() {
@Transient @Transient
val onTabsChanged = Event0(); val onTabsChanged = Event0();
@FormField( @FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -5)
R.string.manage_polycentric_identity, FieldForm.BUTTON,
R.string.manage_your_polycentric_identity, -4
)
@FormFieldButton(R.drawable.ic_person) @FormFieldButton(R.drawable.ic_person)
fun managePolycentricIdentity() { fun managePolycentricIdentity() {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
@@ -59,10 +57,7 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField( @FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -4)
R.string.show_faq, FieldForm.BUTTON,
R.string.get_answers_to_common_questions, -3
)
@FormFieldButton(R.drawable.ic_quiz) @FormFieldButton(R.drawable.ic_quiz)
fun openFAQ() { fun openFAQ() {
try { try {
@@ -72,10 +67,7 @@ class Settings : FragmentedStorageFileJson() {
//Ignored //Ignored
} }
} }
@FormField( @FormField(R.string.show_issues, FieldForm.BUTTON, R.string.a_list_of_user_reported_and_self_reported_issues, -3)
R.string.show_issues, FieldForm.BUTTON,
R.string.a_list_of_user_reported_and_self_reported_issues, -2
)
@FormFieldButton(R.drawable.ic_data_alert) @FormFieldButton(R.drawable.ic_data_alert)
fun openIssues() { fun openIssues() {
try { try {
@@ -107,10 +99,7 @@ class Settings : FragmentedStorageFileJson() {
} }
}*/ }*/
@FormField( @FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -2)
R.string.manage_tabs, FieldForm.BUTTON,
R.string.change_tabs_visible_on_the_home_screen, -1
)
@FormFieldButton(R.drawable.ic_tabs) @FormFieldButton(R.drawable.ic_tabs)
fun manageTabs() { fun manageTabs() {
try { try {
@@ -122,11 +111,39 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField(R.string.language, "group", -1, 0)
var language = LanguageSettings();
@Serializable
class LanguageSettings {
@FormField(R.string.app_language, FieldForm.DROPDOWN, R.string.may_require_restart, 5, "app_language")
@DropdownFieldOptionsId(R.array.app_languages)
var appLanguage: Int = 0;
fun getAppLanguageLocaleString(): String? {
return when(appLanguage) {
0 -> null
1 -> "en";
2 -> "de";
3 -> "es";
4 -> "pt";
5 -> "fr"
6 -> "ja";
7 -> "ko";
8 -> "zh";
9 -> "ru";
10 -> "ar";
else -> null
}
}
}
@FormField(R.string.home, "group", R.string.configure_how_your_home_tab_works_and_feels, 1) @FormField(R.string.home, "group", R.string.configure_how_your_home_tab_works_and_feels, 1)
var home = HomeSettings(); var home = HomeSettings();
@Serializable @Serializable
class HomeSettings { class HomeSettings {
@FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 5) @FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 5)
@DropdownFieldOptionsId(R.array.feed_style) @DropdownFieldOptionsId(R.array.feed_style)
var homeFeedStyle: Int = 1; var homeFeedStyle: Int = 1;
@@ -136,21 +153,45 @@ class Settings : FragmentedStorageFileJson() {
else else
return FeedStyle.THUMBNAIL; return FeedStyle.THUMBNAIL;
} }
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
var previewFeedItems: Boolean = true;
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = false;
@FormField(R.string.clear_hidden, FieldForm.BUTTON, R.string.clear_hidden_description, 8)
@FormFieldButton(R.drawable.ic_visibility_off)
fun clearHidden() {
StateMeta.instance.removeAllHiddenCreators();
StateMeta.instance.removeAllHiddenVideos();
SettingsActivity.getActivity()?.let {
UIDialogs.toast(it, "Creators and videos should show up again");
}
}
} }
@FormField(R.string.search, "group", -1, 2) @FormField(R.string.search, "group", -1, 2)
var search = SearchSettings(); var search = SearchSettings();
@Serializable @Serializable
class SearchSettings { class SearchSettings {
@FormField(R.string.search_history, FieldForm.TOGGLE, -1, 4) @FormField(R.string.search_history, FieldForm.TOGGLE, R.string.may_require_restart, 3)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var searchHistory: Boolean = true; var searchHistory: Boolean = true;
@FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 5) @FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 4)
@DropdownFieldOptionsId(R.array.feed_style) @DropdownFieldOptionsId(R.array.feed_style)
var searchFeedStyle: Int = 1; var searchFeedStyle: Int = 1;
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
var previewFeedItems: Boolean = true;
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = false;
fun getSearchFeedStyle(): FeedStyle { fun getSearchFeedStyle(): FeedStyle {
if(searchFeedStyle == 0) if(searchFeedStyle == 0)
@@ -160,11 +201,21 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField(R.string.subscriptions, "group", R.string.configure_how_your_subscriptions_works_and_feels, 3)
@FormField(R.string.channel, "group", -1, 3)
var channel = ChannelSettings();
@Serializable
class ChannelSettings {
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = false;
}
@FormField(R.string.subscriptions, "group", R.string.configure_how_your_subscriptions_works_and_feels, 4)
var subscriptions = SubscriptionsSettings(); var subscriptions = SubscriptionsSettings();
@Serializable @Serializable
class SubscriptionsSettings { class SubscriptionsSettings {
@FormField(R.string.feed_style, FieldForm.DROPDOWN, -1, 5) @FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 4)
@DropdownFieldOptionsId(R.array.feed_style) @DropdownFieldOptionsId(R.array.feed_style)
var subscriptionsFeedStyle: Int = 1; var subscriptionsFeedStyle: Int = 1;
@@ -175,11 +226,20 @@ class Settings : FragmentedStorageFileJson() {
return FeedStyle.THUMBNAIL; return FeedStyle.THUMBNAIL;
} }
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 6) @FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
var previewFeedItems: Boolean = true;
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
var progressBar: Boolean = false;
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 7)
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var fetchOnAppBoot: Boolean = true; var fetchOnAppBoot: Boolean = true;
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 7) @FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 8)
var fetchOnTabOpen: Boolean = true;
@FormField(R.string.background_update, FieldForm.DROPDOWN, R.string.experimental_background_update_for_subscriptions_cache, 9)
@DropdownFieldOptionsId(R.array.background_interval) @DropdownFieldOptionsId(R.array.background_interval)
var subscriptionsBackgroundUpdateInterval: Int = 0; var subscriptionsBackgroundUpdateInterval: Int = 0;
@@ -195,7 +255,7 @@ class Settings : FragmentedStorageFileJson() {
}; };
@FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 8) @FormField(R.string.subscription_concurrency, FieldForm.DROPDOWN, R.string.specify_how_many_threads_are_used_to_fetch_channels, 10)
@DropdownFieldOptionsId(R.array.thread_count) @DropdownFieldOptionsId(R.array.thread_count)
var subscriptionConcurrency: Int = 3; var subscriptionConcurrency: Int = 3;
@@ -203,22 +263,33 @@ class Settings : FragmentedStorageFileJson() {
return threadIndexToCount(subscriptionConcurrency); return threadIndexToCount(subscriptionConcurrency);
} }
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 9) @FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 11)
var showWatchMetrics: Boolean = false; var showWatchMetrics: Boolean = false;
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 10) @FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 12)
var allowPlaytimeTracking: Boolean = true; var allowPlaytimeTracking: Boolean = true;
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 13)
var alwaysReloadFromCache: Boolean = false;
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 14)
fun clearChannelCache() {
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
ChannelContentCache.instance.clear();
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
}
} }
@FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 4) @FormField(R.string.player, "group", R.string.change_behavior_of_the_player, 5)
var playback = PlaybackSettings(); var playback = PlaybackSettings();
@Serializable @Serializable
class PlaybackSettings { class PlaybackSettings {
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, 0) @FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.languages) @DropdownFieldOptionsId(R.array.audio_languages)
var primaryLanguage: Int = 0; var primaryLanguage: Int = 0;
fun getPrimaryLanguage(context: Context) = context.resources.getStringArray(R.array.languages)[primaryLanguage]; fun getPrimaryLanguage(context: Context) = context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
@FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 1) @FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 1)
@DropdownFieldOptionsId(R.array.playback_speeds) @DropdownFieldOptionsId(R.array.playback_speeds)
@@ -277,10 +348,6 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.resume_after_preview) @DropdownFieldOptionsId(R.array.resume_after_preview)
var resumeAfterPreview: Int = 1; var resumeAfterPreview: Int = 1;
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 8)
var useLiveChatWindow: Boolean = true;
fun shouldResumePreview(previewedPosition: Long): Boolean{ fun shouldResumePreview(previewedPosition: Long): Boolean{
if(resumeAfterPreview == 2) if(resumeAfterPreview == 2)
return true; return true;
@@ -288,9 +355,40 @@ class Settings : FragmentedStorageFileJson() {
return true; return true;
return false; return false;
} }
@FormField(R.string.chapter_update_fps_title, FieldForm.DROPDOWN, R.string.chapter_update_fps_description, 8)
@DropdownFieldOptionsId(R.array.chapter_fps)
var chapterUpdateFPS: Int = 0;
fun getChapterUpdateFrames(): Int {
return when(chapterUpdateFPS) {
0 -> 24
1 -> 30
2 -> 60
3 -> 120
else -> 1
};
}
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 9)
var useLiveChatWindow: Boolean = true;
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 10)
var backgroundSwitchToAudio: Boolean = true;
} }
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 5) @FormField(R.string.comments, "group", R.string.comments_description, 6)
var comments = CommentSettings();
@Serializable
class CommentSettings {
@FormField(R.string.default_comment_section, FieldForm.DROPDOWN, -1, 0)
@DropdownFieldOptionsId(R.array.comment_sections)
var defaultCommentSection: Int = 0;
}
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 7)
var downloads = Downloads(); var downloads = Downloads();
@Serializable @Serializable
class Downloads { class Downloads {
@@ -330,7 +428,7 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField(R.string.browsing, "group", R.string.configure_browsing_behavior, 6) @FormField(R.string.browsing, "group", R.string.configure_browsing_behavior, 8)
var browsing = Browsing(); var browsing = Browsing();
@Serializable @Serializable
class Browsing { class Browsing {
@@ -339,7 +437,7 @@ class Settings : FragmentedStorageFileJson() {
var videoCache: Boolean = true; var videoCache: Boolean = true;
} }
@FormField(R.string.casting, "group", R.string.configure_casting, 7) @FormField(R.string.casting, "group", R.string.configure_casting, 9)
var casting = Casting(); var casting = Casting();
@Serializable @Serializable
class Casting { class Casting {
@@ -347,6 +445,9 @@ class Settings : FragmentedStorageFileJson() {
@Serializable(with = FlexibleBooleanSerializer::class) @Serializable(with = FlexibleBooleanSerializer::class)
var enabled: Boolean = true; var enabled: Boolean = true;
@FormField(R.string.keep_screen_on, FieldForm.TOGGLE, R.string.keep_screen_on_while_casting, 1)
@Serializable(with = FlexibleBooleanSerializer::class)
var keepScreenOn: Boolean = true;
/*TODO: Should we have a different casting quality? /*TODO: Should we have a different casting quality?
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3) @FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
@@ -364,8 +465,7 @@ class Settings : FragmentedStorageFileJson() {
}*/ }*/
} }
@FormField(R.string.logging, FieldForm.GROUP, -1, 10)
@FormField(R.string.logging, FieldForm.GROUP, -1, 8)
var logging = Logging(); var logging = Logging();
@Serializable @Serializable
class Logging { class Logging {
@@ -373,10 +473,7 @@ class Settings : FragmentedStorageFileJson() {
@DropdownFieldOptionsId(R.array.log_levels) @DropdownFieldOptionsId(R.array.log_levels)
var logLevel: Int = 0; var logLevel: Int = 0;
@FormField( @FormField(R.string.submit_logs, FieldForm.BUTTON, R.string.submit_logs_to_help_us_narrow_down_issues, 1)
R.string.submit_logs, FieldForm.BUTTON,
R.string.submit_logs_to_help_us_narrow_down_issues, 1
)
fun submitLogs() { fun submitLogs() {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
@@ -392,23 +489,26 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField(R.string.announcement, FieldForm.GROUP, -1, 11)
@FormField(R.string.announcement, FieldForm.GROUP, -1, 10)
var announcementSettings = AnnouncementSettings(); var announcementSettings = AnnouncementSettings();
@Serializable @Serializable
class AnnouncementSettings { class AnnouncementSettings {
@FormField( @FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
R.string.reset_announcements, FieldForm.BUTTON,
R.string.reset_hidden_announcements, 1
)
fun resetAnnouncements() { fun resetAnnouncements() {
StateAnnouncement.instance.resetAnnouncements(); StateAnnouncement.instance.resetAnnouncements();
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); }; SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
} }
} }
@FormField(R.string.plugins, FieldForm.GROUP, -1, 11) @FormField(R.string.notifications, FieldForm.GROUP, -1, 12)
var notifications = NotificationSettings();
@Serializable
class NotificationSettings {
@FormField(R.string.planned_content_notifications, FieldForm.TOGGLE, R.string.planned_content_notifications_description, 1)
var plannedContentNotification: Boolean = true;
}
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
@Transient @Transient
var plugins = Plugins(); var plugins = Plugins();
@Serializable @Serializable
@@ -417,18 +517,12 @@ class Settings : FragmentedStorageFileJson() {
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0) @FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
var clearCookiesOnLogout: Boolean = true; var clearCookiesOnLogout: Boolean = true;
@FormField( @FormField(R.string.clear_cookies, FieldForm.BUTTON, R.string.clears_in_app_browser_cookies, 1)
R.string.clear_cookies, FieldForm.BUTTON,
R.string.clears_in_app_browser_cookies, 1
)
fun clearCookies() { fun clearCookies() {
val cookieManager: CookieManager = CookieManager.getInstance(); val cookieManager: CookieManager = CookieManager.getInstance();
cookieManager.removeAllCookies(null); cookieManager.removeAllCookies(null);
} }
@FormField( @FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
R.string.reinstall_embedded_plugins, FieldForm.BUTTON,
R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1
)
fun reinstallEmbedded() { fun reinstallEmbedded() {
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
try { try {
@@ -451,7 +545,7 @@ class Settings : FragmentedStorageFileJson() {
} }
@FormField(R.string.external_storage, FieldForm.GROUP, -1, 12) @FormField(R.string.external_storage, FieldForm.GROUP, -1, 14)
var storage = Storage(); var storage = Storage();
@Serializable @Serializable
class Storage { class Storage {
@@ -475,10 +569,17 @@ class Settings : FragmentedStorageFileJson() {
StateApp.instance.changeExternalDownloadDirectory(it); StateApp.instance.changeExternalDownloadDirectory(it);
} }
} }
@FormField(R.string.clear_external_downloads_directory, FieldForm.BUTTON, R.string.clear_the_external_storage_for_download_files, 5)
fun clearStorageDownload() {
Settings.instance.storage.storage_download = null;
Settings.instance.save();
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Cleared download storage directory") };
}
} }
@FormField(R.string.auto_update, "group", R.string.configure_the_auto_updater, 12) @FormField(R.string.auto_update, "group", R.string.configure_the_auto_updater, 15)
var autoUpdate = AutoUpdate(); var autoUpdate = AutoUpdate();
@Serializable @Serializable
class AutoUpdate { class AutoUpdate {
@@ -507,10 +608,7 @@ class Settings : FragmentedStorageFileJson() {
return check == 0 && !BuildConfig.IS_PLAYSTORE_BUILD; return check == 0 && !BuildConfig.IS_PLAYSTORE_BUILD;
} }
@FormField( @FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
R.string.manual_check, FieldForm.BUTTON,
R.string.manually_check_for_updates, 3
)
fun manualCheck() { fun manualCheck() {
if (!BuildConfig.IS_PLAYSTORE_BUILD) { if (!BuildConfig.IS_PLAYSTORE_BUILD) {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
@@ -527,10 +625,7 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField( @FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
R.string.view_changelog, FieldForm.BUTTON,
R.string.review_the_current_and_past_changelogs, 4
)
fun viewChangelog() { fun viewChangelog() {
SettingsActivity.getActivity()?.let { SettingsActivity.getActivity()?.let {
UIDialogs.toast(it.getString(R.string.retrieving_changelog)); UIDialogs.toast(it.getString(R.string.retrieving_changelog));
@@ -550,10 +645,7 @@ class Settings : FragmentedStorageFileJson() {
}; };
} }
@FormField( @FormField(R.string.remove_cached_version, FieldForm.BUTTON, R.string.remove_the_last_downloaded_version, 5)
R.string.remove_cached_version, FieldForm.BUTTON,
R.string.remove_the_last_downloaded_version, 5
)
fun removeCachedVersion() { fun removeCachedVersion() {
StateApp.withContext { StateApp.withContext {
val outputDirectory = File(it.filesDir, "autoupdate"); val outputDirectory = File(it.filesDir, "autoupdate");
@@ -569,7 +661,7 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField(R.string.backup, FieldForm.GROUP, -1, 13) @FormField(R.string.backup, FieldForm.GROUP, -1, 16)
var backup = Backup(); var backup = Backup();
@Serializable @Serializable
class Backup { class Backup {
@@ -603,9 +695,26 @@ class Settings : FragmentedStorageFileJson() {
fun export() { fun export() {
StateBackup.startExternalBackup(); StateBackup.startExternalBackup();
} }
/*
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, 4)
fun import() {
val act = SettingsActivity.getActivity() ?: return;
StateApp.instance.requestFileReadAccess(act, null) {
if(it != null && it.exists()) {
val name = it.name;
val contents = it.readBytes(act);
if(contents != null) {
if(name != null && name.endsWith(".zip", true))
StateBackup.importZipBytes(act, act.lifecycleScope, contents);
}
}
}
}*/
} }
@FormField(R.string.payment, FieldForm.GROUP, -1, 14) @FormField(R.string.payment, FieldForm.GROUP, -1, 17)
var payment = Payment(); var payment = Payment();
@Serializable @Serializable
class Payment { class Payment {
@@ -622,7 +731,16 @@ class Settings : FragmentedStorageFileJson() {
} }
} }
@FormField(R.string.info, FieldForm.GROUP, -1, 15) @FormField(R.string.other, FieldForm.GROUP, -1, 18)
var other = Other();
@Serializable
class Other {
@FormField(R.string.bypass_rotation_prevention, FieldForm.TOGGLE, R.string.bypass_rotation_prevention_description, 1)
@FormFieldWarning(R.string.bypass_rotation_prevention_warning)
var bypassRotationPrevention: Boolean = false;
}
@FormField(R.string.info, FieldForm.GROUP, -1, 19)
var info = Info(); var info = Info();
@Serializable @Serializable
class Info { class Info {
@@ -20,6 +20,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.background.BackgroundWorker import com.futo.platformplayer.background.BackgroundWorker
import com.futo.platformplayer.cache.ChannelContentCache
import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.engine.V8Plugin
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
@@ -111,6 +112,14 @@ class SettingsDev : FragmentedStorageFileJson() {
.build(); .build();
wm.enqueue(req); wm.enqueue(req);
} }
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
R.string.test_background_worker_description, 3)
fun clearChannelContentCache() {
UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache");
ChannelContentCache.instance.clearToday();
UIDialogs.toast(SettingsActivity.getActivity()!!, "Cleared");
}
@Contextual @Contextual
@Transient @Transient
@@ -5,9 +5,12 @@ import android.graphics.Color
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IAudioUrlSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
@@ -52,7 +55,6 @@ class UISlideOverlays {
fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup) { fun showSubscriptionOptionsOverlay(subscription: Subscription, container: ViewGroup) {
val items = arrayListOf<View>(); val items = arrayListOf<View>();
var menu: SlideUpMenuOverlay? = null;
val originalNotif = subscription.doNotifications; val originalNotif = subscription.doNotifications;
val originalLive = subscription.doFetchLive; val originalLive = subscription.doFetchLive;
@@ -60,52 +62,69 @@ class UISlideOverlays {
val originalVideo = subscription.doFetchVideos; val originalVideo = subscription.doFetchVideos;
val originalPosts = subscription.doFetchPosts; val originalPosts = subscription.doFetchPosts;
items.addAll(listOf( StateApp.instance.scopeOrNull?.launch(Dispatchers.IO){
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", { val plugin = StatePlatform.instance.getChannelClient(subscription.channel.url);
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications; val capabilities = plugin.getChannelCapabilities();
}, false),
SlideUpMenuGroup(container.context, "Fetch Settings",
"Depending on the platform you might not need to enable a type for it to be available.",
-1, listOf()),
SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", {
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for finished streams", "fetchStreams", {
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchLive;
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", {
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchLive;
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", {
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchLive;
}, false)));
menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items); withContext(Dispatchers.Main) {
if(subscription.doFetchLive) var menu: SlideUpMenuOverlay? = null;
menu.selectOption(null, "fetchLive", true, true);
if(subscription.doFetchStreams)
menu.selectOption(null, "fetchStreams", true, true);
if(subscription.doFetchVideos)
menu.selectOption(null, "fetchVideos", true, true);
if(subscription.doFetchPosts)
menu.selectOption(null, "fetchPosts", true, true);
menu.onOK.subscribe {
subscription.save();
menu.hide(true);
};
menu.onCancel.subscribe {
subscription.doNotifications = originalNotif;
subscription.doFetchLive = originalLive;
subscription.doFetchStreams = originalStream;
subscription.doFetchVideos = originalVideo;
subscription.doFetchPosts = originalPosts;
};
menu.setOk("Save"); items.addAll(listOf(
SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
}, false),
SlideUpMenuGroup(container.context, "Fetch Settings",
"Depending on the platform you might not need to enable a type for it to be available.",
-1, listOf()),
if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", {
subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
}, false) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for streams", "fetchStreams", {
subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
}, false) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", {
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
}, false) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
SlideUpMenuItem(container.context, R.drawable.ic_play, "Content", "Check for content", "fetchVideos", {
subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
}, false) else null,
if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", {
subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
}, false) else null).filterNotNull());
menu.show(); menu = SlideUpMenuOverlay(container.context, container, "Subscription Settings", null, true, items);
if(subscription.doNotifications)
menu.selectOption(null, "notifications", true, true);
if(subscription.doFetchLive)
menu.selectOption(null, "fetchLive", true, true);
if(subscription.doFetchStreams)
menu.selectOption(null, "fetchStreams", true, true);
if(subscription.doFetchVideos)
menu.selectOption(null, "fetchVideos", true, true);
if(subscription.doFetchPosts)
menu.selectOption(null, "fetchPosts", true, true);
menu.onOK.subscribe {
subscription.save();
menu.hide(true);
};
menu.onCancel.subscribe {
subscription.doNotifications = originalNotif;
subscription.doFetchLive = originalLive;
subscription.doFetchStreams = originalStream;
subscription.doFetchVideos = originalVideo;
subscription.doFetchPosts = originalPosts;
};
menu.setOk("Save");
menu.show();
}
}
} }
fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? { fun showDownloadVideoOverlay(video: IPlatformVideoDetails, container: ViewGroup, contentResolver: ContentResolver? = null): SlideUpMenuOverlay? {
@@ -367,6 +386,33 @@ class UISlideOverlays {
return overlay; return overlay;
} }
fun showCreatePlaylistOverlay(container: ViewGroup, onCreate: (String) -> Unit): SlideUpMenuOverlay {
val nameInput = SlideUpMenuTextInput(container.context, container.context.getString(R.string.name));
val addPlaylistOverlay = SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.create_new_playlist), container.context.getString(R.string.ok), false, nameInput);
addPlaylistOverlay.onOK.subscribe {
val text = nameInput.text;
if (text.isBlank()) {
return@subscribe;
}
addPlaylistOverlay.hide();
nameInput.deactivate();
nameInput.clear();
onCreate(text)
};
addPlaylistOverlay.onCancel.subscribe {
nameInput.deactivate();
nameInput.clear();
};
addPlaylistOverlay.show();
nameInput.activate();
return addPlaylistOverlay
}
fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, vararg actions: SlideUpMenuItem): SlideUpMenuOverlay { fun showVideoOptionsOverlay(video: IPlatformVideo, container: ViewGroup, vararg actions: SlideUpMenuItem): SlideUpMenuOverlay {
val items = arrayListOf<View>(); val items = arrayListOf<View>();
val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist(); val lastUpdated = StatePlaylists.instance.getLastUpdatedPlaylist();
@@ -387,8 +433,13 @@ class UISlideOverlays {
val watchLater = StatePlaylists.instance.getWatchLater(); val watchLater = StatePlaylists.instance.getWatchLater();
items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions", items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
(listOf( (listOf(
SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download), SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download), {
{ showDownloadVideoOverlay(video, container, true); }, false)) showDownloadVideoOverlay(video, container, true);
}, false),
SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", {
StateMeta.instance.addHiddenCreator(video.author.url);
UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
}))
+ actions) + actions)
)); ));
items.add( items.add(
@@ -400,6 +451,13 @@ class UISlideOverlays {
)); ));
val playlistItems = arrayListOf<SlideUpMenuItem>(); val playlistItems = arrayListOf<SlideUpMenuItem>();
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, container.context.getString(R.string.new_playlist), container.context.getString(R.string.add_to_new_playlist), "add_to_new_playlist", {
showCreatePlaylistOverlay(container) {
val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
};
}, false))
for (playlist in allPlaylists) { for (playlist in allPlaylists) {
playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, "${container.context.getString(R.string.add_to)} " + playlist.name + "", "${playlist.videos.size} " + container.context.getString(R.string.videos), "", playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, "${container.context.getString(R.string.add_to)} " + playlist.name + "", "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
{ {
@@ -164,9 +164,7 @@ fun Int.sp(resources: Resources): Int {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this.toFloat(), resources.displayMetrics).toInt() return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, this.toFloat(), resources.displayMetrics).toInt()
} }
fun File.share(context: Context) { fun DocumentFile.share(context: Context) {
val uri = FileProvider.getUriForFile(context, context.resources.getString(R.string.authority), this);
val shareIntent = Intent(); val shareIntent = Intent();
shareIntent.action = Intent.ACTION_SEND; shareIntent.action = Intent.ACTION_SEND;
shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.os.Bundle import android.os.Bundle
@@ -45,6 +46,10 @@ class AddSourceActivity : AppCompatActivity() {
private var _config: SourcePluginConfig? = null; private var _config: SourcePluginConfig? = null;
private var _script: String? = null; private var _script: String? = null;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
@@ -7,6 +8,7 @@ import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator
import com.journeyapps.barcodescanner.CaptureActivity import com.journeyapps.barcodescanner.CaptureActivity
@@ -43,6 +45,10 @@ class AddSourceOptionsActivity : AppCompatActivity() {
} }
} }
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_add_source_options); setContentView(R.layout.activity_add_source_options);
@@ -18,6 +18,7 @@ import com.futo.platformplayer.engine.IV8PluginConfig
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.CaptchaWebViewClient import com.futo.platformplayer.others.CaptchaWebViewClient
import com.futo.platformplayer.others.LoginWebViewClient import com.futo.platformplayer.others.LoginWebViewClient
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
@@ -31,6 +32,10 @@ class CaptchaActivity : AppCompatActivity() {
private lateinit var _webView: WebView; private lateinit var _webView: WebView;
private lateinit var _buttonClose: Button; private lateinit var _buttonClose: Button;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_captcha); setContentView(R.layout.activity_captcha);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -11,6 +12,7 @@ import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.logging.LogLevel import com.futo.platformplayer.logging.LogLevel
import com.futo.platformplayer.logging.Logging import com.futo.platformplayer.logging.Logging
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -27,6 +29,10 @@ class ExceptionActivity : AppCompatActivity() {
private var _file: File? = null; private var _file: File? = null;
private var _submitted = false; private var _submitted = false;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_exception); setContentView(R.layout.activity_exception);
@@ -7,6 +7,8 @@ import android.webkit.ConsoleMessage
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebView import android.webkit.WebView
import android.widget.ImageButton
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.* import com.futo.platformplayer.*
@@ -15,6 +17,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.others.LoginWebViewClient import com.futo.platformplayer.others.LoginWebViewClient
import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
@@ -23,13 +26,25 @@ import kotlinx.serialization.json.Json
class LoginActivity : AppCompatActivity() { class LoginActivity : AppCompatActivity() {
private lateinit var _webView: WebView; private lateinit var _webView: WebView;
private lateinit var _textUrl: TextView;
private lateinit var _buttonClose: ImageButton;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_login); setContentView(R.layout.activity_login);
setNavigationBarColorAndIcons(); setNavigationBarColorAndIcons();
_textUrl = findViewById(R.id.text_url);
_buttonClose = findViewById(R.id.button_close);
_buttonClose.setOnClickListener {
finish();
}
_webView = findViewById(R.id.web_view); _webView = findViewById(R.id.web_view);
_webView.settings.javaScriptEnabled = true; _webView.settings.javaScriptEnabled = true;
CookieManager.getInstance().setAcceptCookie(true); CookieManager.getInstance().setAcceptCookie(true);
@@ -60,6 +75,8 @@ class LoginActivity : AppCompatActivity() {
}; };
var isFirstLoad = true; var isFirstLoad = true;
webViewClient.onPageLoaded.subscribe { view, url -> webViewClient.onPageLoaded.subscribe { view, url ->
_textUrl.setText(url ?: "");
if(!isFirstLoad) if(!isFirstLoad)
return@subscribe; return@subscribe;
isFirstLoad = false; isFirstLoad = false;
@@ -7,6 +7,8 @@ import android.content.pm.ActivityInfo
import android.content.res.Configuration import android.content.res.Configuration
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.preference.PreferenceManager
import android.util.Log
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
@@ -154,6 +156,11 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
override fun attachBaseContext(newBase: Context?) {
Logger.i(TAG, "MainActivity.attachBaseContext")
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
StateApp.instance.setGlobalContext(this, lifecycleScope); StateApp.instance.setGlobalContext(this, lifecycleScope);
StateApp.instance.mainAppStarting(this); StateApp.instance.mainAppStarting(this);
@@ -321,6 +328,15 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
fragCurrent.onOrientationChanged(it); fragCurrent.onOrientationChanged(it);
if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED) if(_fragVideoDetail.state == VideoDetailFragment.State.MAXIMIZED)
_fragVideoDetail.onOrientationChanged(it); _fragVideoDetail.onOrientationChanged(it);
else if(Settings.instance.other.bypassRotationPrevention)
{
requestedOrientation = when(orientation) {
OrientationManager.Orientation.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
OrientationManager.Orientation.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
OrientationManager.Orientation.REVERSED_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
OrientationManager.Orientation.REVERSED_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
}
}
}; };
_orientationManager.enable(); _orientationManager.enable();
@@ -497,6 +513,14 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
}; };
startActivity(intent); startActivity(intent);
} }
else if(targetData.startsWith("grayjay://video/")) {
val videoUrl = targetData.substring("grayjay://video/".length);
navigate(_fragVideoDetail, videoUrl);
}
else if(targetData.startsWith("grayjay://channel/")) {
val channelUrl = targetData.substring("grayjay://channel/".length);
navigate(_fragMainChannel, channelUrl);
}
} }
"content" -> { "content" -> {
if(!handleContent(targetData, intent.type)) { if(!handleContent(targetData, intent.type)) {
@@ -583,6 +607,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateBackup.importZipBytes(this, lifecycleScope, data); StateBackup.importZipBytes(this, lifecycleScope, data);
return true; return true;
} }
else if(file.lowercase().endsWith(".txt") || mime == "text/plain") {
return handleUnknownText(String(data));
}
return false; return false;
} }
fun handleFile(file: String): Boolean { fun handleFile(file: String): Boolean {
@@ -600,6 +627,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateBackup.importZipBytes(this, lifecycleScope, readSharedFile(file)); StateBackup.importZipBytes(this, lifecycleScope, readSharedFile(file));
return true; return true;
} }
else if(file.lowercase().endsWith(".txt")) {
return handleUnknownText(String(readSharedFile(file)));
}
return false; return false;
} }
fun handleReconstruction(recon: String) { fun handleReconstruction(recon: String) {
@@ -625,6 +655,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
} }
} }
fun handleUnknownText(text: String): Boolean {
try {
if(text.startsWith("@/Subscription") || text.startsWith("Subscriptions")) {
val lines = text.split("\n").map { it.trim() }.drop(1).filter { it.isNotEmpty() };
navigate(_fragImportSubscriptions, lines);
return true;
}
}
catch(ex: Throwable) {
Logger.e(TAG, ex.message, ex);
UIDialogs.showGeneralErrorDialog(this, getString(R.string.failed_to_parse_text_file), ex);
}
return false;
}
fun handleUnknownJson(name: String?, json: String): Boolean { fun handleUnknownJson(name: String?, json: String): Boolean {
val context = this; val context = this;
@@ -745,6 +789,9 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
StateSaved.instance.setVideoToOpenBlocking(null); StateSaved.instance.setVideoToOpenBlocking(null);
} }
inline fun <reified T> isFragmentActive(): Boolean {
return fragCurrent is T;
}
/** /**
* Navigate takes a MainFragment, and makes them the current main visible view * Navigate takes a MainFragment, and makes them the current main visible view
@@ -838,15 +885,20 @@ class MainActivity : AppCompatActivity, IWithResultLauncher {
if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) { if((fragment?.isOverlay ?: false) && fragBeforeOverlay != null) {
navigate(fragBeforeOverlay!!, null, false, true); navigate(fragBeforeOverlay!!, null, false, true);
} else {
}
else {
val last = _queue.lastOrNull(); val last = _queue.lastOrNull();
if (last != null) { if (last != null) {
_queue.remove(last); _queue.remove(last);
navigate(last.first, last.second, false, true); navigate(last.first, last.second, false, true);
} else } else {
finish(); if (_fragVideoDetail.state == VideoDetailFragment.State.CLOSED) {
finish();
} else {
UIDialogs.showConfirmationDialog(this, "There is a video playing, are you sure you want to exit the app?", {
finish();
})
}
}
} }
} }
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.widget.ImageButton import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@@ -10,6 +11,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.AnyAdapterView import com.futo.platformplayer.views.AnyAdapterView
import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
import com.futo.platformplayer.views.adapters.ItemMoveCallback import com.futo.platformplayer.views.adapters.ItemMoveCallback
@@ -23,6 +25,10 @@ class ManageTabsActivity : AppCompatActivity() {
private lateinit var _recyclerTabs: RecyclerView; private lateinit var _recyclerTabs: RecyclerView;
private lateinit var _touchHelper: ItemTouchHelper; private lateinit var _touchHelper: ItemTouchHelper;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_manage_tabs); setContentView(R.layout.activity_manage_tabs);
@@ -16,6 +16,7 @@ import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import com.futo.polycentric.core.* import com.futo.polycentric.core.*
@@ -33,6 +34,10 @@ class PolycentricBackupActivity : AppCompatActivity() {
private lateinit var _exportBundle: String; private lateinit var _exportBundle: String;
private lateinit var _textQR: TextView; private lateinit var _textQR: TextView;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_backup); setContentView(R.layout.activity_polycentric_backup);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.widget.EditText import android.widget.EditText
@@ -9,8 +10,10 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.ProcessHandle import com.futo.polycentric.core.ProcessHandle
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
@@ -28,6 +31,10 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
private var _creating = false; private var _creating = false;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_create_profile); setContentView(R.layout.activity_polycentric_create_profile);
@@ -76,7 +83,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
try { try {
Logger.i(TAG, "Started backfill"); Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServers(); processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill"); Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e); Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
@@ -15,6 +16,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
import com.futo.polycentric.core.Store import com.futo.polycentric.core.Store
@@ -27,6 +29,10 @@ class PolycentricHomeActivity : AppCompatActivity() {
private lateinit var _buttonImportProfile: BigButton; private lateinit var _buttonImportProfile: BigButton;
private lateinit var _layoutButtons: LinearLayout; private lateinit var _layoutButtons: LinearLayout;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_home); setContentView(R.layout.activity_polycentric_home);
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.widget.EditText import android.widget.EditText
@@ -12,6 +13,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.polycentric.core.* import com.futo.polycentric.core.*
import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator
@@ -39,6 +41,10 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
} }
} }
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_import_profile); setContentView(R.layout.activity_polycentric_import_profile);
@@ -2,6 +2,7 @@ package com.futo.platformplayer.activities
import android.app.Activity import android.app.Activity
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
@@ -18,6 +19,7 @@ import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.dialogs.CommentDialog import com.futo.platformplayer.dialogs.CommentDialog
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
@@ -29,6 +31,7 @@ import com.futo.polycentric.core.Store
import com.futo.polycentric.core.Synchronization import com.futo.polycentric.core.Synchronization
import com.futo.polycentric.core.SystemState import com.futo.polycentric.core.SystemState
import com.futo.polycentric.core.toURLInfoDataLink import com.futo.polycentric.core.toURLInfoDataLink
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
import com.github.dhaval2404.imagepicker.ImagePicker import com.github.dhaval2404.imagepicker.ImagePicker
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -47,6 +50,10 @@ class PolycentricProfileActivity : AppCompatActivity() {
private lateinit var _imagePolycentric: ImageView; private lateinit var _imagePolycentric: ImageView;
private var _avatarUri: Uri? = null; private var _avatarUri: Uri? = null;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_profile); setContentView(R.layout.activity_polycentric_profile);
@@ -188,7 +195,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
if (hasChanges) { if (hasChanges) {
try { try {
Logger.i(TAG, "Started backfill"); Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServers(); processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill"); Logger.i(TAG, "Finished backfill");
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.changes_have_been_saved)); UIDialogs.toast(this@PolycentricProfileActivity, getString(R.string.changes_have_been_saved));
@@ -222,7 +229,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
val avatar = systemState.avatar.selectBestImage(dp_80 * dp_80); val avatar = systemState.avatar.selectBestImage(dp_80 * dp_80);
Glide.with(_imagePolycentric) Glide.with(_imagePolycentric)
.load(avatar?.toURLInfoDataLink(processHandle.system.toProto(), processHandle.processSecret.process.toProto(), systemState.servers.toList())) .load(avatar?.toURLInfoSystemLinkUrl(processHandle.system.toProto(), avatar.process, systemState.servers.toList()))
.placeholder(R.drawable.placeholder_profile) .placeholder(R.drawable.placeholder_profile)
.crossfade() .crossfade()
.into(_imagePolycentric) .into(_imagePolycentric)
@@ -1,5 +1,6 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
@@ -7,12 +8,17 @@ import android.widget.ImageButton
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.setNavigationBarColorAndIcons import com.futo.platformplayer.setNavigationBarColorAndIcons
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.buttons.BigButton import com.futo.platformplayer.views.buttons.BigButton
class PolycentricWhyActivity : AppCompatActivity() { class PolycentricWhyActivity : AppCompatActivity() {
private lateinit var _buttonVideo: BigButton; private lateinit var _buttonVideo: BigButton;
private lateinit var _buttonTechnical: BigButton; private lateinit var _buttonTechnical: BigButton;
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_polycentric_why); setContentView(R.layout.activity_polycentric_why);
@@ -1,6 +1,7 @@
package com.futo.platformplayer.activities package com.futo.platformplayer.activities
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
@@ -13,6 +14,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.views.Loader import com.futo.platformplayer.views.Loader
import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.ReadOnlyTextField import com.futo.platformplayer.views.fields.ReadOnlyTextField
@@ -28,6 +30,10 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
private var _isFinished = false; private var _isFinished = false;
override fun attachBaseContext(newBase: Context?) {
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_settings); setContentView(R.layout.activity_settings);
@@ -43,6 +49,11 @@ class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving"); Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
_form.setObjectValues(); _form.setObjectValues();
Settings.instance.save(); Settings.instance.save();
if(field.descriptor?.id == "app_language") {
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
StateApp.instance.setLocaleSetting(this, Settings.instance.language.getAppLanguageLocaleString());
}
}; };
_buttonBack.setOnClickListener { _buttonBack.setOnClickListener {
finish(); finish();
@@ -8,16 +8,20 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.BufferedReader import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.io.StringWriter import java.io.StringWriter
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
class HttpContext : AutoCloseable { class HttpContext : AutoCloseable {
private val _stream: BufferedReader; private val _inputStream: InputStream;
private var _responseStream: OutputStream? = null; private var _responseStream: OutputStream? = null;
var id: String? = null; var id: String? = null;
var head: String = ""; var head: String = "";
var headers: HttpHeaders = HttpHeaders(); var headers: HttpHeaders = HttpHeaders();
@@ -39,76 +43,130 @@ class HttpContext : AutoCloseable {
private val _responseHeaders: HttpHeaders = HttpHeaders(); private val _responseHeaders: HttpHeaders = HttpHeaders();
constructor(stream: BufferedReader, responseStream: OutputStream? = null, requestId: String? = null, timeout: Int? = null) { constructor(inputStream: InputStream, responseStream: OutputStream? = null, requestId: String? = null, timeout: Int? = null) {
_stream = stream; _inputStream = inputStream;
_responseStream = responseStream; _responseStream = responseStream;
this.id = requestId; this.id = requestId;
try { val headerBytes = readHeaderBytes()
head = stream.readLine() ?: throw EmptyRequestException("No head found"); ByteArrayInputStream(headerBytes).use {
} val reader = it.bufferedReader(Charsets.UTF_8)
catch(ex: SocketTimeoutException) { try {
if((timeout ?: 0) > 0) head = reader.readLine() ?: throw EmptyRequestException("No head found");
throw KeepAliveTimeoutException("Keep-Alive timedout", ex); }
throw ex; catch(ex: SocketTimeoutException) {
} if((timeout ?: 0) > 0)
throw KeepAliveTimeoutException("Keep-Alive timedout", ex);
val methodEndIndex = head.indexOf(' '); throw ex;
val urlEndIndex = head.indexOf(' ', methodEndIndex + 1);
if (methodEndIndex == -1 || urlEndIndex == -1) {
Logger.w(TAG, "Skipped request, wrong format.");
throw IllegalStateException("Invalid request");
}
method = head.substring(0, methodEndIndex);
path = head.substring(methodEndIndex + 1, urlEndIndex);
if (path.contains("?")) {
val queryPartIndex = path.indexOf("?");
val queryParts = path.substring(queryPartIndex + 1).split("&");
path = path.substring(0, queryPartIndex);
for(queryPart in queryParts) {
val eqIndex = queryPart.indexOf("=");
if(eqIndex > 0)
query.put(queryPart.substring(0, eqIndex), queryPart.substring(eqIndex + 1));
else
query.put(queryPart, "");
} }
}
while (true) { val methodEndIndex = head.indexOf(' ');
val line = stream.readLine(); val urlEndIndex = head.indexOf(' ', methodEndIndex + 1);
val headerEndIndex = line.indexOf(":"); if (methodEndIndex == -1 || urlEndIndex == -1) {
if (headerEndIndex == -1) Logger.w(TAG, "Skipped request, wrong format.");
break; throw IllegalStateException("Invalid request");
}
val headerKey = line.substring(0, headerEndIndex).lowercase() method = head.substring(0, methodEndIndex);
val headerValue = line.substring(headerEndIndex + 1).trim(); path = head.substring(methodEndIndex + 1, urlEndIndex);
headers[headerKey] = headerValue;
when(headerKey) { if (path.contains("?")) {
"content-length" -> contentLength = headerValue.toLong(); val queryPartIndex = path.indexOf("?");
"content-type" -> contentType = headerValue; val queryParts = path.substring(queryPartIndex + 1).split("&");
"connection" -> keepAlive = headerValue.lowercase() == "keep-alive"; path = path.substring(0, queryPartIndex);
"keep-alive" -> {
val keepAliveParams = headerValue.split(","); for(queryPart in queryParts) {
for(keepAliveParam in keepAliveParams) { val eqIndex = queryPart.indexOf("=");
val eqIndex = keepAliveParam.indexOf("="); if(eqIndex > 0)
if(eqIndex > 0){ query.put(queryPart.substring(0, eqIndex), queryPart.substring(eqIndex + 1));
when(keepAliveParam.substring(0, eqIndex)) { else
"timeout" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt(); query.put(queryPart, "");
"max" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt(); }
}
while (true) {
val line = reader.readLine();
val headerEndIndex = line.indexOf(":");
if (headerEndIndex == -1)
break;
val headerKey = line.substring(0, headerEndIndex).lowercase()
val headerValue = line.substring(headerEndIndex + 1).trim();
headers[headerKey] = headerValue;
when(headerKey) {
"content-length" -> contentLength = headerValue.toLong();
"content-type" -> contentType = headerValue;
"connection" -> keepAlive = headerValue.lowercase() == "keep-alive";
"keep-alive" -> {
val keepAliveParams = headerValue.split(",");
for(keepAliveParam in keepAliveParams) {
val eqIndex = keepAliveParam.indexOf("=");
if(eqIndex > 0){
when(keepAliveParam.substring(0, eqIndex)) {
"timeout" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
"max" -> keepAliveTimeout = keepAliveParam.substring(eqIndex+1).toInt();
}
} }
} }
} }
} }
if(line.isNullOrEmpty())
break;
} }
if(line.isNullOrEmpty())
break;
} }
} }
private fun readHeaderBytes(): ByteArray {
val headerBytes = ByteArrayOutputStream()
var crlfCount = 0
while (crlfCount < 4) {
val b = _inputStream.read()
if (b == -1) {
throw IOException("Unexpected end of stream while reading headers")
}
if (b == 0x0D || b == 0x0A) { // CR or LF
crlfCount++
} else {
crlfCount = 0
}
headerBytes.write(b)
}
return headerBytes.toByteArray()
}
fun readContentBytes(buffer: ByteArray, length: Int): Int {
val remainingBytes = (contentLength - _totalRead).coerceAtMost(length.toLong()).toInt()
val read = _inputStream.read(buffer, 0, remainingBytes);
if (read > 0) {
_totalRead += read
}
return read;
}
fun readContentString(): String {
val byteArrayOutputStream = ByteArrayOutputStream()
val buffer = ByteArray(4096)
var read: Int
while (true) {
read = readContentBytes(buffer, buffer.size)
if (read <= 0) break
byteArrayOutputStream.write(buffer, 0, read)
}
return byteArrayOutputStream.toString(Charsets.UTF_8.name())
}
inline fun <reified T> readContentJson() : T {
return Serializer.json.decodeFromString(readContentString());
}
fun skipBody() {
if (contentLength > 0)
_inputStream.skip(contentLength - _totalRead)
}
fun getHttpHeaderString(): String { fun getHttpHeaderString(): String {
val writer = StringWriter(); val writer = StringWriter();
writer.write(head + "\r\n"); writer.write(head + "\r\n");
@@ -139,8 +197,13 @@ class HttpContext : AutoCloseable {
} }
fun respondCode(status: Int, headers: HttpHeaders, body: String? = null) { fun respondCode(status: Int, headers: HttpHeaders, body: String? = null) {
val bytes = body?.toByteArray(Charsets.UTF_8); val bytes = body?.toByteArray(Charsets.UTF_8);
if(body != null && headers.get("content-length").isNullOrEmpty()) if(headers.get("content-length").isNullOrEmpty()) {
headers.put("content-length", bytes!!.size.toString()); if (body != null) {
headers.put("content-length", bytes!!.size.toString());
} else {
headers.put("content-length", "0")
}
}
respond(status, headers) { responseStream -> respond(status, headers) { responseStream ->
if(body != null) { if(body != null) {
responseStream.write(bytes!!); responseStream.write(bytes!!);
@@ -161,8 +224,7 @@ class HttpContext : AutoCloseable {
headersToRespond.put("keep-alive", "timeout=5, max=1000"); headersToRespond.put("keep-alive", "timeout=5, max=1000");
} }
val responseHeader = HttpResponse(status, headers); val responseHeader = HttpResponse(status, headersToRespond);
responseStream.write(responseHeader.getHttpHeaderBytes()); responseStream.write(responseHeader.getHttpHeaderBytes());
if(method != "HEAD") { if(method != "HEAD") {
@@ -172,38 +234,9 @@ class HttpContext : AutoCloseable {
statusCode = status; statusCode = status;
} }
fun readContentBytes(buffer: CharArray, length: Int) : Int {
val reading = Math.min(length, (contentLength - _totalRead).toInt());
val read = _stream.read(buffer, 0, reading);
_totalRead += read;
//TODO: Fix this properly
if(contentLength - _totalRead < 400 && read < length) {
_totalRead = contentLength;
}
return read;
}
fun readContentString() : String{
val writer = StringWriter();
var read = 0;
val buffer = CharArray(4096);
do {
read = readContentBytes(buffer, buffer.size);
writer.write(buffer, 0, read);
} while(read > 0);
return writer.toString();
}
inline fun <reified T> readContentJson() : T {
return Serializer.json.decodeFromString(readContentString());
}
fun skipBody() {
if(contentLength > 0)
_stream.skip(contentLength - _totalRead);
}
override fun close() { override fun close() {
if(!keepAlive) { if(!keepAlive) {
_stream?.close(); _inputStream.close();
_responseStream?.close(); _responseStream?.close();
} }
} }
@@ -5,8 +5,7 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler
import com.futo.platformplayer.api.http.server.handlers.HttpHandler import com.futo.platformplayer.api.http.server.handlers.HttpHandler
import java.io.BufferedReader import java.io.BufferedInputStream
import java.io.InputStreamReader
import java.io.OutputStream import java.io.OutputStream
import java.lang.reflect.Field import java.lang.reflect.Field
import java.lang.reflect.Method import java.lang.reflect.Method
@@ -18,6 +17,7 @@ import java.util.*
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.stream.IntStream.range import java.util.stream.IntStream.range
import kotlin.collections.HashMap
class ManagedHttpServer(private val _requestedPort: Int = 0) { class ManagedHttpServer(private val _requestedPort: Int = 0) {
private val _client : ManagedHttpClient = ManagedHttpClient(); private val _client : ManagedHttpClient = ManagedHttpClient();
@@ -29,7 +29,8 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
var port = 0 var port = 0
private set; private set;
private val _handlers = mutableListOf<HttpHandler>(); private val _handlers = hashMapOf<String, HashMap<String, HttpHandler>>()
private val _headHandlers = hashMapOf<String, HttpHandler>()
private var _workerPool: ExecutorService? = null; private var _workerPool: ExecutorService? = null;
@Synchronized @Synchronized
@@ -76,12 +77,12 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
private fun handleClientRequest(socket: Socket) { private fun handleClientRequest(socket: Socket) {
_workerPool?.submit { _workerPool?.submit {
val requestReader = BufferedReader(InputStreamReader(socket.getInputStream())) val requestStream = BufferedInputStream(socket.getInputStream());
val responseStream = socket.getOutputStream(); val responseStream = socket.getOutputStream();
val requestId = UUID.randomUUID().toString().substring(0, 5); val requestId = UUID.randomUUID().toString().substring(0, 5);
try { try {
keepAliveLoop(requestReader, responseStream, requestId) { req -> keepAliveLoop(requestStream, responseStream, requestId) { req ->
req.use { httpContext -> req.use { httpContext ->
if(!httpContext.path.startsWith("/plugin/")) if(!httpContext.path.startsWith("/plugin/"))
Logger.i(TAG, "[${req.id}] ${httpContext.method}: ${httpContext.path}") Logger.i(TAG, "[${req.id}] ${httpContext.method}: ${httpContext.path}")
@@ -107,7 +108,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
Logger.e(TAG, "Failed to handle client request.", e); Logger.e(TAG, "Failed to handle client request.", e);
} }
finally { finally {
requestReader.close(); requestStream.close();
responseStream.close(); responseStream.close();
} }
}; };
@@ -115,32 +116,61 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
fun getHandler(method: String, path: String) : HttpHandler? { fun getHandler(method: String, path: String) : HttpHandler? {
synchronized(_handlers) { synchronized(_handlers) {
//TODO: Support regex paths? if (method == "HEAD") {
if(method == "HEAD") return _headHandlers[path]
return _handlers.firstOrNull { it.path == path && (it.allowHEAD || it.method == "HEAD") } }
return _handlers.firstOrNull { it.method == method && it.path == path };
val handlerMap = _handlers[method] ?: return null
return handlerMap[path]
} }
} }
fun addHandler(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler { fun addHandler(handler: HttpHandler, withHEAD: Boolean = false) : HttpHandler {
synchronized(_handlers) { synchronized(_handlers) {
_handlers.add(handler);
handler.allowHEAD = withHEAD; handler.allowHEAD = withHEAD;
var handlerMap: HashMap<String, HttpHandler>? = _handlers[handler.method];
if (handlerMap == null) {
handlerMap = hashMapOf()
_handlers[handler.method] = handlerMap
}
handlerMap[handler.path] = handler;
if (handler.allowHEAD || handler.method == "HEAD") {
_headHandlers[handler.path] = handler
}
} }
return handler; return handler;
} }
fun removeHandler(method: String, path: String) { fun removeHandler(method: String, path: String) {
synchronized(_handlers) { synchronized(_handlers) {
val handler = getHandler(method, path); val handlerMap = _handlers[method] ?: return
if(handler != null) val handler = handlerMap.remove(path) ?: return
_handlers.remove(handler); if (method == "HEAD" || handler.allowHEAD) {
_headHandlers.remove(path)
}
} }
} }
fun removeAllHandlers(tag: String? = null) { fun removeAllHandlers(tag: String? = null) {
synchronized(_handlers) { synchronized(_handlers) {
if(tag == null) if(tag == null)
_handlers.clear(); _handlers.clear();
else else {
_handlers.removeIf { it.tag == tag }; for (pair in _handlers) {
val toRemove = ArrayList<String>()
for (innerPair in pair.value) {
if (innerPair.value.tag == tag) {
toRemove.add(innerPair.key)
if (pair.key == "HEAD" || innerPair.value.allowHEAD) {
_headHandlers.remove(innerPair.key)
}
}
}
for (x in toRemove)
pair.value.remove(x)
}
}
} }
} }
fun addBridgeHandlers(obj: Any, tag: String? = null) { fun addBridgeHandlers(obj: Any, tag: String? = null) {
@@ -188,7 +218,7 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
} }
} }
private fun keepAliveLoop(requestReader: BufferedReader, responseStream: OutputStream, requestId: String, handler: (HttpContext)->Unit) { private fun keepAliveLoop(requestReader: BufferedInputStream, responseStream: OutputStream, requestId: String, handler: (HttpContext)->Unit) {
val stopCount = _stopCount; val stopCount = _stopCount;
var keepAlive = false; var keepAlive = false;
var requestsMax = 0; var requestsMax = 0;
@@ -7,7 +7,6 @@ class HttpConstantHandler(method: String, path: String, val content: String, val
val headers = this.headers.clone(); val headers = this.headers.clone();
if(contentType != null) if(contentType != null)
headers["Content-Type"] = contentType; headers["Content-Type"] = contentType;
headers["Content-Length"] = content.length.toString();
httpContext.respondCode(200, headers, content); httpContext.respondCode(200, headers, content);
} }
@@ -1,14 +1,16 @@
package com.futo.platformplayer.api.http.server.handlers package com.futo.platformplayer.api.http.server.handlers
import com.futo.platformplayer.api.http.server.HttpContext import com.futo.platformplayer.api.http.server.HttpContext
import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.nio.file.Files import java.nio.file.Files
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.zip.GZIPOutputStream import java.util.zip.GZIPOutputStream
class HttpFileHandler(method: String, path: String, private val contentType: String, private val filePath: String, private val closeAfterRequest: Boolean = false): HttpHandler(method, path) { class HttpFileHandler(method: String, path: String, private val contentType: String, private val filePath: String): HttpHandler(method, path) {
override fun handle(httpContext: HttpContext) { override fun handle(httpContext: HttpContext) {
val requestHeaders = httpContext.headers; val requestHeaders = httpContext.headers;
val responseHeaders = this.headers.clone(); val responseHeaders = this.headers.clone();
@@ -30,19 +32,13 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
responseHeaders["Content-Disposition"] = "attachment; filename=\"${file.name.replace("\"", "\\\"")}\"" responseHeaders["Content-Disposition"] = "attachment; filename=\"${file.name.replace("\"", "\\\"")}\""
val acceptEncoding = requestHeaders["Accept-Encoding"]
val shouldGzip = acceptEncoding != null && acceptEncoding.split(',').any { it.trim().equals("gzip", ignoreCase = true) || it == "*" }
if (shouldGzip) {
responseHeaders["Content-Encoding"] = "gzip"
}
val range = requestHeaders["Range"] val range = requestHeaders["Range"]
var start: Long val start: Long
val end: Long val end: Long
if (range != null && range.startsWith("bytes=")) { if (range != null && range.startsWith("bytes=")) {
val parts = range.substring(6).split("-") val parts = range.substring(6).split("-")
start = parts[0].toLong() start = parts[0].toLong()
end = parts.getOrNull(1)?.toLong() ?: (file.length() - 1) end = parts.getOrNull(1)?.toLongOrNull() ?: (file.length() - 1)
responseHeaders["Content-Range"] = "bytes $start-$end/${file.length()}" responseHeaders["Content-Range"] = "bytes $start-$end/${file.length()}"
} else { } else {
start = 0 start = 0
@@ -51,18 +47,19 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
var totalBytesSent = 0 var totalBytesSent = 0
val contentLength = end - start + 1 val contentLength = end - start + 1
Logger.i(TAG, "Sending $contentLength bytes (start: $start, end: $end, shouldGzip: $shouldGzip)")
responseHeaders["Content-Length"] = contentLength.toString() responseHeaders["Content-Length"] = contentLength.toString()
Logger.i(TAG, "Sending $contentLength bytes (start: $start, end: $end)")
file.inputStream().use { inputStream -> file.inputStream().use { inputStream ->
httpContext.respond(if (range == null) 200 else 206, responseHeaders) { responseStream -> httpContext.respond(if (range != null) 206 else 200, responseHeaders) { responseStream ->
try { try {
val buffer = ByteArray(8192) val buffer = ByteArray(8192)
inputStream.skip(start) inputStream.skip(start)
var current = start
val outputStream = if (shouldGzip) GZIPOutputStream(responseStream) else responseStream val outputStream = responseStream
while (true) { while (true) {
val expectedBytesRead = (end - start + 1).coerceAtMost(buffer.size.toLong()); val expectedBytesRead = (end - current + 1).coerceAtMost(buffer.size.toLong());
val bytesRead = inputStream.read(buffer); val bytesRead = inputStream.read(buffer);
if (bytesRead < 0) { if (bytesRead < 0) {
Logger.i(TAG, "End of file reached") Logger.i(TAG, "End of file reached")
@@ -73,27 +70,21 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
outputStream.write(buffer, 0, bytesToSend) outputStream.write(buffer, 0, bytesToSend)
totalBytesSent += bytesToSend totalBytesSent += bytesToSend
Logger.v(TAG, "Sent bytes $start-${start + bytesToSend}, totalBytesSent=$totalBytesSent") Logger.v(TAG, "Sent bytes $current-${current + bytesToSend}, totalBytesSent=$totalBytesSent")
start += bytesToSend.toLong() current += bytesToSend.toLong()
if (start >= end) { if (current >= end) {
Logger.i(TAG, "Expected amount of bytes sent") Logger.i(TAG, "Expected amount of bytes sent")
break break
} }
} }
Logger.i(TAG, "Finished sending file (segment)") Logger.i(TAG, "Finished sending file (segment)")
if (shouldGzip) (outputStream as GZIPOutputStream).finish()
outputStream.flush() outputStream.flush()
} catch (e: Exception) { } catch (e: Exception) {
httpContext.respondCode(500, headers) httpContext.respondCode(500, headers)
} }
} }
if (closeAfterRequest) {
httpContext.keepAlive = false;
}
} }
} }
@@ -15,6 +15,7 @@ abstract class HttpHandler(val method: String, val path: String) {
headers.put(key, value); headers.put(key, value);
return this; return this;
} }
fun withContentType(contentType: String) = withHeader("Content-Type", contentType); fun withContentType(contentType: String) = withHeader("Content-Type", contentType);
fun withTag(tag: String) : HttpHandler { fun withTag(tag: String) : HttpHandler {
@@ -4,17 +4,10 @@ import com.futo.platformplayer.api.http.server.HttpContext
class HttpOptionsAllowHandler(path: String) : HttpHandler("OPTIONS", path) { class HttpOptionsAllowHandler(path: String) : HttpHandler("OPTIONS", path) {
override fun handle(httpContext: HttpContext) { override fun handle(httpContext: HttpContext) {
//Just allow whatever is requested val newHeaders = headers.clone()
newHeaders.put("Access-Control-Allow-Origin", "*")
val requestedOrigin = httpContext.headers.getOrDefault("Access-Control-Request-Origin", ""); newHeaders.put("Access-Control-Allow-Methods", "*")
val requestedMethods = httpContext.headers.getOrDefault("Access-Control-Request-Method", ""); newHeaders.put("Access-Control-Allow-Headers", "*")
val requestedHeaders = httpContext.headers.getOrDefault("Access-Control-Request-Headers", "");
val newHeaders = headers.clone();
newHeaders.put("Allow", requestedMethods);
newHeaders.put("Access-Control-Allow-Methods", requestedMethods);
newHeaders.put("Access-Control-Allow-Headers", "*");
httpContext.respondCode(200, newHeaders); httpContext.respondCode(200, newHeaders);
} }
} }
@@ -1,11 +1,20 @@
package com.futo.platformplayer.api.http.server.handlers package com.futo.platformplayer.api.http.server.handlers
import android.net.Uri import android.net.Uri
import android.util.Log
import com.futo.platformplayer.api.http.server.HttpContext import com.futo.platformplayer.api.http.server.HttpContext
import com.futo.platformplayer.api.http.server.HttpHeaders import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.parsers.HttpResponseParser
import com.futo.platformplayer.readLine
import java.io.InputStream
import java.io.OutputStream
import java.lang.Exception
import java.net.Socket
import javax.net.ssl.SSLSocketFactory
class HttpProxyHandler(method: String, path: String, val targetUrl: String): HttpHandler(method, path) { class HttpProxyHandler(method: String, path: String, val targetUrl: String, private val useTcp: Boolean = false): HttpHandler(method, path) {
var content: String? = null; var content: String? = null;
var contentType: String? = null; var contentType: String? = null;
@@ -17,10 +26,17 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
private var _injectHost = false; private var _injectHost = false;
private var _injectReferer = false; private var _injectReferer = false;
private val _client = ManagedHttpClient(); private val _client = ManagedHttpClient();
override fun handle(context: HttpContext) { override fun handle(context: HttpContext) {
if (useTcp) {
handleWithTcp(context)
} else {
handleWithOkHttp(context)
}
}
private fun handleWithOkHttp(context: HttpContext) {
val proxyHeaders = HashMap<String, String>(); val proxyHeaders = HashMap<String, String>();
for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) }) for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) })
proxyHeaders[header.key] = header.value; proxyHeaders[header.key] = header.value;
@@ -34,8 +50,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
proxyHeaders.put("Referer", targetUrl); proxyHeaders.put("Referer", targetUrl);
val useMethod = if (method == "inherit") context.method else method; val useMethod = if (method == "inherit") context.method else method;
//Logger.i(TAG, "Proxied Request ${useMethod}: ${targetUrl}"); Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}");
//Logger.i(TAG, "Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n")); Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
val resp = when (useMethod) { val resp = when (useMethod) {
"GET" -> _client.get(targetUrl, proxyHeaders); "GET" -> _client.get(targetUrl, proxyHeaders);
@@ -44,8 +60,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders); else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders);
}; };
//Logger.i(TAG, "Proxied Response [${resp.code}]"); Logger.i(TAG, "Proxied Response [${resp.code}]");
val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) }); val headersFiltered = HttpHeaders(resp.getHeadersFlat().filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) });
for(newHeader in headers) for(newHeader in headers)
headersFiltered.put(newHeader.key, newHeader.value); headersFiltered.put(newHeader.key, newHeader.value);
@@ -65,6 +81,140 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
} }
} }
private fun handleWithTcp(context: HttpContext) {
if (content != null)
throw NotImplementedError("Content body is not supported")
val proxyHeaders = HashMap<String, String>();
for (header in context.headers.filter { !_ignoreRequestHeaders.contains(it.key.lowercase()) })
proxyHeaders[header.key] = header.value;
for (injectHeader in _injectRequestHeader)
proxyHeaders[injectHeader.first] = injectHeader.second;
val parsed = Uri.parse(targetUrl);
if(_injectHost)
proxyHeaders.put("Host", parsed.host!!);
if(_injectReferer)
proxyHeaders.put("Referer", targetUrl);
val useMethod = if (method == "inherit") context.method else method;
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
Logger.i(TAG, "handleWithTcp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
makeTcpRequest(proxyHeaders, useMethod, parsed, context)
}
private fun makeTcpRequest(proxyHeaders: HashMap<String, String>, useMethod: String, parsed: Uri, context: HttpContext) {
val requestBuilder = StringBuilder()
requestBuilder.append("$useMethod $parsed HTTP/1.1\r\n")
proxyHeaders.forEach { (key, value) -> requestBuilder.append("$key: $value\r\n") }
requestBuilder.append("\r\n")
val port = if (parsed.port == -1) {
when (parsed.scheme) {
"https" -> 443
"http" -> 80
else -> throw Exception("Unhandled scheme")
}
} else {
parsed.port
}
val socket = if (parsed.scheme == "https") {
val sslSocketFactory = SSLSocketFactory.getDefault() as SSLSocketFactory
sslSocketFactory.createSocket(parsed.host, port)
} else {
Socket(parsed.host, port)
}
socket.use { s ->
s.getOutputStream().write(requestBuilder.toString().encodeToByteArray())
val inputStream = s.getInputStream()
val resp = HttpResponseParser(inputStream)
if (resp.statusCode == 302) {
val location = resp.location!!
Logger.i(TAG, "handleWithTcp Proxied ${resp.statusCode} following redirect to $location");
makeTcpRequest(proxyHeaders, useMethod, Uri.parse(location)!!, context)
} else {
val isChunked = resp.transferEncoding.equals("chunked", ignoreCase = true)
val contentLength = resp.contentLength.toInt()
val headersFiltered = HttpHeaders(resp.headers.filter { !_ignoreResponseHeaders.contains(it.key.lowercase()) });
for (newHeader in headers)
headersFiltered.put(newHeader.key, newHeader.value);
context.respond(resp.statusCode, headersFiltered) { responseStream ->
if (isChunked) {
Logger.i(TAG, "handleWithTcp handleChunkedTransfer");
handleChunkedTransfer(inputStream, responseStream)
} else if (contentLength > 0) {
Logger.i(TAG, "handleWithTcp transferFixedLengthContent $contentLength");
transferFixedLengthContent(inputStream, responseStream, contentLength)
} else if (contentLength == -1) {
Logger.i(TAG, "handleWithTcp transferUntilEndOfStream");
transferUntilEndOfStream(inputStream, responseStream)
} else {
Logger.i(TAG, "handleWithTcp no content");
}
}
}
}
}
private fun handleChunkedTransfer(inputStream: InputStream, responseStream: OutputStream) {
var line: String?
val buffer = ByteArray(8192)
while (inputStream.readLine().also { line = it } != null) {
val size = line!!.trim().toInt(16)
responseStream.write(line!!.encodeToByteArray())
responseStream.write("\r\n".encodeToByteArray())
if (size == 0) {
inputStream.skip(2)
responseStream.write("\r\n".encodeToByteArray())
break
}
var totalRead = 0
while (totalRead < size) {
val read = inputStream.read(buffer, 0, minOf(buffer.size, size - totalRead))
if (read == -1) break
responseStream.write(buffer, 0, read)
totalRead += read
}
inputStream.skip(2)
responseStream.write("\r\n".encodeToByteArray())
responseStream.flush()
}
}
private fun transferFixedLengthContent(inputStream: InputStream, responseStream: OutputStream, contentLength: Int) {
val buffer = ByteArray(8192)
var totalRead = 0
while (totalRead < contentLength) {
val read = inputStream.read(buffer, 0, minOf(buffer.size, contentLength - totalRead))
if (read == -1) break
responseStream.write(buffer, 0, read)
totalRead += read
}
responseStream.flush()
}
private fun transferUntilEndOfStream(inputStream: InputStream, responseStream: OutputStream) {
val buffer = ByteArray(8192)
var read: Int
while (inputStream.read(buffer).also { read = it } >= 0) {
responseStream.write(buffer, 0, read)
}
responseStream.flush()
}
fun withContent(body: String) : HttpProxyHandler { fun withContent(body: String) : HttpProxyHandler {
this.content = body; this.content = body;
return this; return this;
@@ -92,4 +242,8 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String): Htt
_ignoreRequestHeaders.add("referer"); _ignoreRequestHeaders.add("referer");
return this; return this;
} }
companion object {
private const val TAG = "HttpProxyHandler"
}
} }
@@ -10,7 +10,7 @@ import com.futo.platformplayer.getOrThrow
* A link to a channel, often with its own name and thumbnail * A link to a channel, often with its own name and thumbnail
*/ */
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
class PlatformAuthorLink { open class PlatformAuthorLink {
val id: PlatformID; val id: PlatformID;
val name: String; val name: String;
val url: String; val url: String;
@@ -28,6 +28,9 @@ class PlatformAuthorLink {
companion object { companion object {
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink { fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink {
if(value.has("membershipUrl"))
return PlatformAuthorMembershipLink.fromV8(config, value);
val context = "AuthorLink" val context = "AuthorLink"
return PlatformAuthorLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)), return PlatformAuthorLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)),
value.getOrThrow(config ,"name", context), value.getOrThrow(config ,"name", context),
@@ -0,0 +1,33 @@
package com.futo.platformplayer.api.media.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
/**
* A link to a channel, often with its own name and thumbnail
*/
@kotlinx.serialization.Serializable
class PlatformAuthorMembershipLink: PlatformAuthorLink {
val membershipUrl: String?;
constructor(id: PlatformID, name: String, url: String, thumbnail: String? = null, subscribers: Long? = null, membershipUrl: String? = null): super(id, name, url, thumbnail, subscribers)
{
this.membershipUrl = membershipUrl;
}
companion object {
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorMembershipLink {
val context = "AuthorMembershipLink"
return PlatformAuthorMembershipLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)),
value.getOrThrow(config ,"name", context),
value.getOrThrow(config, "url", context),
value.getOrDefault<String>(config, "thumbnail", context, null),
if(value.has("subscribers")) value.getOrThrow(config,"subscribers", context) else null,
if(value.has("membershipUrl")) value.getOrThrow(config, "membershipUrl", context) else null
);
}
}
}
@@ -29,6 +29,7 @@ class ResultCapabilities(
const val TYPE_LIVE = "LIVE"; const val TYPE_LIVE = "LIVE";
const val TYPE_POSTS = "POSTS"; const val TYPE_POSTS = "POSTS";
const val TYPE_MIXED = "MIXED"; const val TYPE_MIXED = "MIXED";
const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS";
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL"; const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
@@ -20,6 +20,10 @@ class Thumbnails {
fun getLQThumbnail() : String? { fun getLQThumbnail() : String? {
return sources.firstOrNull()?.url; return sources.firstOrNull()?.url;
} }
fun getMinimumThumbnail(quality: Int): String? {
return sources.firstOrNull { it.quality >= quality }?.url ?: getHQThumbnail();
}
fun hasMultiple() = sources.size > 1; fun hasMultiple() = sources.size > 1;
@@ -6,8 +6,8 @@ import com.futo.platformplayer.api.media.models.contents.ContentType
interface IChapter { interface IChapter {
val name: String; val name: String;
val type: ChapterType; val type: ChapterType;
val timeStart: Int; val timeStart: Double;
val timeEnd: Int; val timeEnd: Double;
} }
enum class ChapterType(val value: Int) { enum class ChapterType(val value: Int) {
@@ -13,6 +13,7 @@ enum class ContentType(val value: Int) {
NESTED_VIDEO(11), NESTED_VIDEO(11),
LOCKED(70),
PLACEHOLDER(90), PLACEHOLDER(90),
DEFERRED(91); DEFERRED(91);
@@ -0,0 +1,13 @@
package com.futo.platformplayer.api.media.models.locked
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
interface IPlatformLockedContent: IPlatformContent {
val lockContentType: ContentType;
val lockDescription: String?;
val unlockUrl: String?;
val contentName: String?;
val contentThumbnails: Thumbnails;
}
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.video
import com.futo.platformplayer.api.media.models.contents.ContentType 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.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.nested.IPlatformNestedContent
import com.futo.platformplayer.api.media.models.post.IPlatformPost import com.futo.platformplayer.api.media.models.post.IPlatformPost
import com.futo.platformplayer.serializers.PlatformContentSerializer import com.futo.platformplayer.serializers.PlatformContentSerializer
@@ -18,6 +19,7 @@ interface SerializedPlatformContent: IPlatformContent {
ContentType.MEDIA -> SerializedPlatformVideo.fromVideo(content as IPlatformVideo); ContentType.MEDIA -> SerializedPlatformVideo.fromVideo(content as IPlatformVideo);
ContentType.NESTED_VIDEO -> SerializedPlatformNestedContent.fromNested(content as IPlatformNestedContent); ContentType.NESTED_VIDEO -> SerializedPlatformNestedContent.fromNested(content as IPlatformNestedContent);
ContentType.POST -> SerializedPlatformPost.fromPost(content as IPlatformPost); ContentType.POST -> SerializedPlatformPost.fromPost(content as IPlatformPost);
ContentType.LOCKED -> SerializedPlatformLockedContent.fromLocked(content as IPlatformLockedContent);
else -> throw NotImplementedError("Content type ${content.contentType} not implemented"); else -> throw NotImplementedError("Content type ${content.contentType} not implemented");
}; };
} }
@@ -0,0 +1,62 @@
package com.futo.platformplayer.api.media.models.video
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.states.StatePlatform
import com.futo.polycentric.core.combineHashCodes
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.OffsetDateTime
@kotlinx.serialization.Serializable
open class SerializedPlatformLockedContent(
override val id: PlatformID,
override val name: String,
override val author: PlatformAuthorLink,
@kotlinx.serialization.Serializable(with = OffsetDateTimeNullableSerializer::class)
override val datetime: OffsetDateTime?,
override val url: String,
override val shareUrl: String,
override val lockContentType: ContentType,
override val contentName: String?,
override val lockDescription: String? = null,
override val unlockUrl: String? = null,
override val contentThumbnails: Thumbnails
) : IPlatformLockedContent, SerializedPlatformContent {
final override val contentType: ContentType get() = ContentType.LOCKED;
override fun toJson() : String {
return Json.encodeToString(this);
}
override fun fromJson(str : String) : SerializedPlatformLockedContent {
return Serializer.json.decodeFromString<SerializedPlatformLockedContent>(str);
}
override fun fromJsonArray(str : String) : Array<SerializedPlatformContent> {
return Serializer.json.decodeFromString<Array<SerializedPlatformContent>>(str);
}
companion object {
fun fromLocked(content: IPlatformLockedContent) : SerializedPlatformLockedContent {
return SerializedPlatformLockedContent(
content.id,
content.name,
content.author,
content.datetime,
content.url,
content.shareUrl,
content.lockContentType,
content.contentName,
content.lockDescription,
content.unlockUrl,
content.contentThumbnails
);
}
}
}
@@ -92,6 +92,19 @@ open class JSClient : IPlatformClient {
val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true val enableInSearch get() = descriptor.appSettings.tabEnabled.enableSearch ?: true
val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true val enableInHome get() = descriptor.appSettings.tabEnabled.enableHome ?: true
fun getSubscriptionRateLimit(): Int? {
val pluginRateLimit = config.subscriptionRateLimit;
val settingsRateLimit = descriptor.appSettings.rateLimit.getSubRateLimit();
if(settingsRateLimit > 0) {
if(pluginRateLimit != null)
return settingsRateLimit.coerceAtMost(pluginRateLimit);
else
return settingsRateLimit;
}
else
return pluginRateLimit;
}
val onDisabled = Event1<JSClient>(); val onDisabled = Event1<JSClient>();
val onCaptchaException = Event2<JSClient, ScriptCaptchaRequiredException>(); val onCaptchaException = Event2<JSClient, ScriptCaptchaRequiredException>();
@@ -571,7 +584,7 @@ open class JSClient : IPlatformClient {
if(it.containsKey(claimType)) { if(it.containsKey(claimType)) {
val templates = it[claimType]; val templates = it[claimType];
if(templates != null) if(templates != null)
for(value in values.keys.sortedBy { it }) { for(value in values.keys.sortedBy { if(it == config.primaryClaimFieldType) Int.MIN_VALUE else it }) {
if(templates.containsKey(value)) { if(templates.containsKey(value)) {
return templates[value]!!.replace("{{CLAIMVALUE}}", values[value]!!); return templates[value]!!.replace("{{CLAIMVALUE}}", values[value]!!);
} }
@@ -1,20 +1,17 @@
package com.futo.platformplayer.api.media.platforms.js package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.logging.Logger
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) { data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? = null, val headers: Map<String, Map<String, String>> = mapOf()) {
override fun toString(): String { override fun toString(): String {
return "(headers: '$headers', cookieString: '$cookieMap')"; return "(headers: '$headers', cookieString: '$cookieMap')";
} }
fun toEncrypted(): String{ fun toEncrypted(): String{
return EncryptionProvider.instance.encrypt(serialize()); return SourceEncrypted.fromDecrypted { serialize() }.toJson();
} }
private fun serialize(): String { private fun serialize(): String {
@@ -25,20 +22,10 @@ data class SourceAuth(val cookieMap: HashMap<String, HashMap<String, String>>? =
val TAG = "SourceAuth"; val TAG = "SourceAuth";
fun fromEncrypted(encrypted: String?): SourceAuth? { fun fromEncrypted(encrypted: String?): SourceAuth? {
if(encrypted == null) return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
return null;
val decrypted = EncryptionProvider.instance.decrypt(encrypted);
try {
return deserialize(decrypted);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to deserialize authentication", ex);
return null;
}
} }
fun deserialize(str: String): SourceAuth { private fun deserialize(str: String): SourceAuth {
val data = Json.decodeFromString<SerializedAuth>(str); val data = Json.decodeFromString<SerializedAuth>(str);
return SourceAuth(data.cookieMap, data.headers); return SourceAuth(data.cookieMap, data.headers);
} }
@@ -1,7 +1,5 @@
package com.futo.platformplayer.api.media.platforms.js package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.encryption.EncryptionProvider
import com.futo.platformplayer.logging.Logger
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@@ -13,7 +11,7 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
} }
fun toEncrypted(): String{ fun toEncrypted(): String{
return EncryptionProvider.instance.encrypt(serialize()); return SourceEncrypted.fromDecrypted { serialize() }.toJson();
} }
private fun serialize(): String { private fun serialize(): String {
@@ -21,20 +19,10 @@ data class SourceCaptchaData(val cookieMap: HashMap<String, HashMap<String, Stri
} }
companion object { companion object {
val TAG = "SourceAuth"; val TAG = "SourceCaptchaData";
fun fromEncrypted(encrypted: String?): SourceCaptchaData? { fun fromEncrypted(encrypted: String?): SourceCaptchaData? {
if(encrypted == null) return SourceEncrypted.decryptEncrypted(encrypted) { deserialize(it) };
return null;
val decrypted = EncryptionProvider.instance.decrypt(encrypted);
try {
return deserialize(decrypted);
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed to deserialize authentication", ex);
return null;
}
} }
fun deserialize(str: String): SourceCaptchaData { fun deserialize(str: String): SourceCaptchaData {
@@ -0,0 +1,59 @@
package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.encryption.GEncryptionProvider
import com.futo.platformplayer.encryption.GEncryptionProviderV0
import com.futo.platformplayer.logging.Logger
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.lang.Exception
@Serializable
data class SourceEncrypted(
val encrypted: String,
val version: Int = GEncryptionProvider.version
) {
fun toJson(): String {
return Json.encodeToString(this);
}
companion object {
fun fromDecrypted(serializer: () -> String): SourceEncrypted {
return SourceEncrypted(GEncryptionProvider.instance.encrypt(serializer()));
}
fun <T> decryptEncrypted(encrypted: String?, deserializer: (decrypted: String) -> T): T? {
if(encrypted == null)
return null;
try {
val encryptedSourceAuth = Json.decodeFromString<SourceEncrypted>(encrypted)
if (encryptedSourceAuth.version != GEncryptionProvider.version) {
throw Exception("Invalid encryption version.");
}
val decrypted = GEncryptionProvider.instance.decrypt(encryptedSourceAuth.encrypted);
try {
return deserializer(decrypted);
} catch(ex: Throwable) {
Logger.e(SourceAuth.TAG, "Failed to deserialize SourceEncrypted<T>", ex);
return null;
}
} catch (e: Throwable) {
//Try to fall back to old mechanism, remove this eventually
if (!encrypted.contains("version")) {
val decrypted = GEncryptionProviderV0.instance.decrypt(encrypted);
try {
return deserializer(decrypted);
} catch (ex: Throwable) {
Logger.e(SourceAuth.TAG, "Failed to deserialize SourceEncrypted<T>", ex);
return null;
}
} else {
return null;
}
}
}
}
}
@@ -41,10 +41,12 @@ class SourcePluginConfig(
val constants: HashMap<String, String> = hashMapOf(), val constants: HashMap<String, String> = hashMapOf(),
//TODO: These should be vals...but prob for serialization reasons cannot be changed. //TODO: These should be vals...but prob for serialization reasons cannot be changed.
var platformUrl: String? = null,
var subscriptionRateLimit: Int? = null, var subscriptionRateLimit: Int? = null,
var enableInSearch: Boolean = true, var enableInSearch: Boolean = true,
var enableInHome: Boolean = true, var enableInHome: Boolean = true,
var supportedClaimTypes: List<Int> = listOf() var supportedClaimTypes: List<Int> = listOf(),
var primaryClaimFieldType: Int? = null
) : IV8PluginConfig { ) : IV8PluginConfig {
val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl); val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl);
@@ -142,7 +144,10 @@ class SourcePluginConfig(
val description: String, val description: String,
val type: String, val type: String,
val default: String? = null, val default: String? = null,
val variable: String? = null val variable: String? = null,
val dependency: String? = null,
val warningDialog: String? = null,
val options: List<String>? = null
) { ) {
@kotlinx.serialization.Transient @kotlinx.serialization.Transient
val variableOrName: String get() = variable ?: name; val variableOrName: String get() = variable ?: name;
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.platforms.js
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
import com.futo.platformplayer.views.fields.DropdownFieldOptions
import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FieldForm
import com.futo.platformplayer.views.fields.FormField import com.futo.platformplayer.views.fields.FormField
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -79,6 +80,29 @@ class SourcePluginDescriptor {
var enableSearch: Boolean? = null; var enableSearch: Boolean? = null;
} }
@FormField(R.string.ratelimit, "group", R.string.ratelimit_description, 3)
var rateLimit = RateLimit();
@Serializable
class RateLimit {
@FormField(R.string.subscriptions, FieldForm.DROPDOWN, R.string.ratelimit_sub_setting_description, 1)
@DropdownFieldOptions("Plugin defined", "25", "50", "75", "100", "125", "150", "200")
var rateLimitSubs: Int = 0;
fun getSubRateLimit(): Int {
return when(rateLimitSubs) {
0 -> -1
1 -> 25
2 -> 50
3 -> 75
4 -> 100
5 -> 125
6 -> 150
7 -> 200
else -> -1
}
}
}
fun loadDefaults(config: SourcePluginConfig) { fun loadDefaults(config: SourcePluginConfig) {
@@ -6,10 +6,13 @@ import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourceAuth import com.futo.platformplayer.api.media.platforms.js.SourceAuth
import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData import com.futo.platformplayer.api.media.platforms.js.SourceCaptchaData
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
import com.futo.platformplayer.matchesDomain import com.futo.platformplayer.matchesDomain
class JSHttpClient : ManagedHttpClient { class JSHttpClient : ManagedHttpClient {
private val _jsClient: JSClient?; private val _jsClient: JSClient?;
private val _jsConfig: SourcePluginConfig?;
private val _auth: SourceAuth?; private val _auth: SourceAuth?;
private val _captcha: SourceCaptchaData?; private val _captcha: SourceCaptchaData?;
@@ -20,8 +23,9 @@ class JSHttpClient : ManagedHttpClient {
private var _currentCookieMap: HashMap<String, HashMap<String, String>>; private var _currentCookieMap: HashMap<String, HashMap<String, String>>;
constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null) : super() { constructor(jsClient: JSClient?, auth: SourceAuth? = null, captcha: SourceCaptchaData? = null, config: SourcePluginConfig? = null) : super() {
_jsClient = jsClient; _jsClient = jsClient;
_jsConfig = config;
_auth = auth; _auth = auth;
_captcha = captcha; _captcha = captcha;
@@ -87,7 +91,11 @@ class JSHttpClient : ManagedHttpClient {
} }
} }
_jsClient?.validateUrlOrThrow(request.url.toString()); if(_jsClient != null)
_jsClient?.validateUrlOrThrow(request.url.toString());
else if (_jsConfig != null && !_jsConfig.isUrlAllowed(request.url.toString()))
throw ScriptImplementationException(_jsConfig, "Attempted to access non-whitelisted url: ${request.url.toString()}\nAdd it to your config");
return newBuilder?.let { it.build() } ?: request; return newBuilder?.let { it.build() } ?: request;
} }
@@ -23,6 +23,7 @@ interface IJSContent: IPlatformContent {
ContentType.POST -> JSPost(config, obj); ContentType.POST -> JSPost(config, obj);
ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj); ContentType.NESTED_VIDEO -> JSNestedMediaContent(config, obj);
ContentType.PLAYLIST -> JSPlaylist(config, obj); ContentType.PLAYLIST -> JSPlaylist(config, obj);
ContentType.LOCKED -> JSLockedContent(config, obj);
else -> throw NotImplementedError("Unknown content type ${type}"); else -> throw NotImplementedError("Unknown content type ${type}");
} }
} }
@@ -12,10 +12,10 @@ import com.futo.platformplayer.getOrThrow
class JSChapter : IChapter { class JSChapter : IChapter {
override val name: String; override val name: String;
override val type: ChapterType; override val type: ChapterType;
override val timeStart: Int; override val timeStart: Double;
override val timeEnd: Int; override val timeEnd: Double;
constructor(name: String, timeStart: Int, timeEnd: Int, type: ChapterType = ChapterType.NORMAL) { constructor(name: String, timeStart: Double, timeEnd: Double, type: ChapterType = ChapterType.NORMAL) {
this.name = name; this.name = name;
this.timeStart = timeStart; this.timeStart = timeStart;
this.timeEnd = timeEnd; this.timeEnd = timeEnd;
@@ -29,8 +29,8 @@ class JSChapter : IChapter {
val name = obj.getOrThrow<String>(config,"name", context); val name = obj.getOrThrow<String>(config,"name", context);
val type = ChapterType.fromInt(obj.getOrDefault<Int>(config, "type", context, ChapterType.NORMAL.value) ?: ChapterType.NORMAL.value); val type = ChapterType.fromInt(obj.getOrDefault<Int>(config, "type", context, ChapterType.NORMAL.value) ?: ChapterType.NORMAL.value);
val timeStart = obj.getOrThrow<Int>(config, "timeStart", context); val timeStart = obj.getOrThrow<Double>(config, "timeStart", context);
val timeEnd = obj.getOrThrow<Int>(config, "timeEnd", context); val timeEnd = obj.getOrThrow<Double>(config, "timeEnd", context);
return JSChapter(name, timeStart, timeEnd, type); return JSChapter(name, timeStart, timeEnd, type);
} }
@@ -0,0 +1,36 @@
package com.futo.platformplayer.api.media.platforms.js.models
import com.caoccao.javet.values.reference.V8ValueObject
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.locked.IPlatformLockedContent
import com.futo.platformplayer.api.media.models.nested.IPlatformNestedContent
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.getOrDefault
import com.futo.platformplayer.getOrThrow
import com.futo.platformplayer.states.StatePlatform
//TODO: Refactor into video-only
class JSLockedContent: IPlatformLockedContent, JSContent {
override val contentType: ContentType get() = ContentType.LOCKED;
override val lockContentType: ContentType get() = ContentType.MEDIA;
override val lockDescription: String?;
override val unlockUrl: String?;
override val contentName: String?;
override val contentThumbnails: Thumbnails;
constructor(config: SourcePluginConfig, obj: V8ValueObject): super(config, obj) {
val contextName = "PlatformLockedContent";
this.contentName = obj.getOrDefault(config, "contentName", contextName, null);
this.contentThumbnails = obj.getOrDefault<V8ValueObject?>(config, "contentThumbnails", contextName, null)?.let {
return@let Thumbnails.fromV8(config, it);
} ?: Thumbnails();
lockDescription = obj.getOrDefault(config, "lockDescription", contextName, null);
unlockUrl = obj.getOrDefault(config, "unlockUrl", contextName, null);
}
}
@@ -59,8 +59,6 @@ abstract class JSPager<T> : IPager<T> {
} }
override fun getResults(): List<T> { override fun getResults(): List<T> {
warnIfMainThread("JSPager.getResults");
val previousResults = _lastResults?.let { val previousResults = _lastResults?.let {
if(!_resultChanged) if(!_resultChanged)
return@let it; return@let it;
@@ -70,6 +68,7 @@ abstract class JSPager<T> : IPager<T> {
if(previousResults != null) if(previousResults != null)
return previousResults; return previousResults;
warnIfMainThread("JSPager.getResults");
val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager"); val items = pager.getOrThrow<V8ValueArray>(config, "results", "JSPager");
val newResults = items.toArray() val newResults = items.toArray()
.map { convertResult(it as V8ValueObject) } .map { convertResult(it as V8ValueObject) }
@@ -52,7 +52,7 @@ class DedupContentPager : IPager<IPlatformContent>, IAsyncPager<IPlatformContent
val sameItems = results.filter { isSameItem(result, it) }; val sameItems = results.filter { isSameItem(result, it) };
val platformItemMap = sameItems.groupBy { it.id.pluginId }.mapValues { (_, items) -> items.first() } val platformItemMap = sameItems.groupBy { it.id.pluginId }.mapValues { (_, items) -> items.first() }
val bestPlatform = _preferredPlatform.map { it.lowercase() }.firstOrNull { platformItemMap.containsKey(it) } val bestPlatform = _preferredPlatform.map { it.lowercase() }.firstOrNull { platformItemMap.containsKey(it) }
val bestItem = platformItemMap[bestPlatform] ?: sameItems.first() val bestItem = platformItemMap[bestPlatform] ?: sameItems.firstOrNull();
resultsToRemove.addAll(sameItems.filter { it != bestItem }); resultsToRemove.addAll(sameItems.filter { it != bestItem });
} }
@@ -6,16 +6,13 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.media.MediaSession2Service.MediaNotification
import androidx.concurrent.futures.CallbackToFutureAdapter
import androidx.concurrent.futures.ResolvableFuture
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ListenableWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition import com.bumptech.glide.request.transition.Transition
import com.futo.platformplayer.Settings
import com.futo.platformplayer.activities.MainActivity import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.contents.IPlatformContent import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
@@ -23,15 +20,11 @@ import com.futo.platformplayer.getNowDiffSeconds
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.models.Subscription
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StateNotifications
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.adapters.viewholders.TabViewHolder import com.futo.platformplayer.toHumanNowDiffString
import com.google.common.util.concurrent.ListenableFuture import com.futo.platformplayer.toHumanNowDiffStringMinDay
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.time.OffsetDateTime import java.time.OffsetDateTime
@@ -53,8 +46,10 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
this.setSound(null, null); this.setSound(null, null);
}; };
notificationManager.createNotificationChannel(notificationChannel); notificationManager.createNotificationChannel(notificationChannel);
val contentChannel = StateNotifications.instance.contentNotifChannel
notificationManager.createNotificationChannel(contentChannel);
try { try {
doSubscriptionUpdating(notificationManager, notificationChannel); doSubscriptionUpdating(notificationManager, notificationChannel, contentChannel);
} }
catch(ex: Throwable) { catch(ex: Throwable) {
exception = ex; exception = ex;
@@ -76,13 +71,13 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
} }
suspend fun doSubscriptionUpdating(manager: NotificationManager, notificationChannel: NotificationChannel) { suspend fun doSubscriptionUpdating(manager: NotificationManager, backgroundChannel: NotificationChannel, contentChannel: NotificationChannel) {
val notif = NotificationCompat.Builder(appContext, notificationChannel.id) val notif = NotificationCompat.Builder(appContext, backgroundChannel.id)
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground) .setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
.setContentTitle("Grayjay") .setContentTitle("Grayjay")
.setContentText("Updating subscriptions...") .setContentText("Updating subscriptions...")
.setSilent(true) .setSilent(true)
.setChannelId(notificationChannel.id) .setChannelId(backgroundChannel.id)
.setProgress(1, 0, true); .setProgress(1, 0, true);
manager.notify(12, notif.build()); manager.notify(12, notif.build());
@@ -93,6 +88,7 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
val newItems = mutableListOf<IPlatformContent>(); val newItems = mutableListOf<IPlatformContent>();
val now = OffsetDateTime.now(); val now = OffsetDateTime.now();
val threeDays = now.minusDays(4);
val contentNotifs = mutableListOf<Pair<Subscription, IPlatformContent>>(); val contentNotifs = mutableListOf<Pair<Subscription, IPlatformContent>>();
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val results = StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total -> val results = StateSubscriptions.instance.getSubscriptionsFeedWithExceptions(true, false,this, { progress, total ->
@@ -110,8 +106,14 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
synchronized(newSubChanges) { synchronized(newSubChanges) {
if(!newSubChanges.contains(sub)) { if(!newSubChanges.contains(sub)) {
newSubChanges.add(sub); newSubChanges.add(sub);
if(sub.doNotifications && content.datetime?.let { it < now } == true) if(sub.doNotifications) {
contentNotifs.add(Pair(sub, content)); if(content.datetime != null) {
if(content.datetime!! <= now.plusMinutes(StateNotifications.instance.plannedWarningMinutesEarly) && content.datetime!! > threeDays)
contentNotifs.add(Pair(sub, content));
else if(content.datetime!! > now.plusMinutes(StateNotifications.instance.plannedWarningMinutesEarly) && Settings.instance.notifications.plannedContentNotification)
StateNotifications.instance.scheduleContentNotification(applicationContext, content);
}
}
} }
newItems.add(content); newItems.add(content);
} }
@@ -134,22 +136,7 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
val items = contentNotifs.take(5).toList() val items = contentNotifs.take(5).toList()
for(i in items.indices) { for(i in items.indices) {
val contentNotif = items.get(i); val contentNotif = items.get(i);
val thumbnail = if(contentNotif.second is IPlatformVideo) (contentNotif.second as IPlatformVideo).thumbnails.getHQThumbnail() StateNotifications.instance.notifyNewContentWithThumbnail(appContext, manager, contentChannel, 13 + i, contentNotif.second);
else null;
if(thumbnail != null)
Glide.with(appContext).asBitmap()
.load(thumbnail)
.into(object: CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, resource);
}
override fun onLoadCleared(placeholder: Drawable?) {}
override fun onLoadFailed(errorDrawable: Drawable?) {
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, null);
}
})
else
notifyNewContent(manager, notificationChannel, 13 + i, contentNotif.first, contentNotif.second, null);
} }
} }
catch(ex: Throwable) { catch(ex: Throwable) {
@@ -164,20 +151,4 @@ class BackgroundWorker(private val appContext: Context, private val workerParams
.setSilent(true) .setSilent(true)
.setChannelId(notificationChannel.id).build());*/ .setChannelId(notificationChannel.id).build());*/
} }
fun notifyNewContent(manager: NotificationManager, notificationChannel: NotificationChannel, id: Int, sub: Subscription, content: IPlatformContent, thumbnail: Bitmap? = null) {
val notifBuilder = NotificationCompat.Builder(appContext, notificationChannel.id)
.setSmallIcon(com.futo.platformplayer.R.drawable.foreground)
.setContentTitle("New by [${sub.channel.name}]")
.setContentText("${content.name}")
.setSilent(true)
.setContentIntent(PendingIntent.getActivity(this.appContext, 0, MainActivity.getVideoIntent(this.appContext, content.url),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
.setChannelId(notificationChannel.id);
if(thumbnail != null) {
//notifBuilder.setLargeIcon(thumbnail);
notifBuilder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(thumbnail).bigLargeIcon(null as Bitmap?));
}
manager.notify(id, notifBuilder.build());
}
} }
@@ -1,37 +0,0 @@
package com.futo.platformplayer.builders
import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import java.io.PrintWriter
import java.io.StringWriter
class HlsBuilder {
companion object{
fun generateOnDemandHLS(vidSource: IVideoSource, vidUrl: String, audioSource: IAudioSource?, audioUrl: String?, subtitleSource: ISubtitleSource?, subtitleUrl: String?): String {
val hlsBuilder = StringWriter()
PrintWriter(hlsBuilder).use { writer ->
writer.println("#EXTM3U")
// Audio
if (audioSource != null && audioUrl != null) {
val audioFormat = audioSource.container.substringAfter("/")
writer.println("#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"audio\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${audioUrl.replace("&", "&amp;")}\",FORMAT=\"$audioFormat\"")
}
// Subtitles
if (subtitleSource != null && subtitleUrl != null) {
val subtitleFormat = subtitleSource.format ?: "text/vtt"
writer.println("#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",LANGUAGE=\"en\",NAME=\"English\",AUTOSELECT=YES,DEFAULT=YES,URI=\"${subtitleUrl.replace("&", "&amp;")}\",FORMAT=\"$subtitleFormat\"")
}
// Video
val videoFormat = vidSource.container.substringAfter("/")
writer.println("#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS=\"${vidSource.codec}\",RESOLUTION=${vidSource.width}x${vidSource.height}${if (audioSource != null) ",AUDIO=\"audio\"" else ""}${if (subtitleSource != null) ",SUBTITLES=\"subs\"" else ""},FORMAT=\"$videoFormat\"")
writer.println(vidUrl.replace("&", "&amp;"))
}
return hlsBuilder.toString()
}
}
}
@@ -51,6 +51,22 @@ class ChannelContentCache {
Logger.i(TAG, "ChannelContentCache time: ${initializeTime}ms channels: ${allFiles.size}, videos: ${totalItems}, trimmed: ${trimmed}, total: ${totalItems - trimmed}"); Logger.i(TAG, "ChannelContentCache time: ${initializeTime}ms channels: ${allFiles.size}, videos: ${totalItems}, trimmed: ${trimmed}, total: ${totalItems - trimmed}");
} }
fun clear() {
synchronized(_channelContents) {
for(channel in _channelContents)
for(content in channel.value.getItems())
uncacheContent(content);
}
}
fun clearToday() {
val yesterday = OffsetDateTime.now().minusDays(1);
synchronized(_channelContents) {
for(channel in _channelContents)
for(content in channel.value.getItems().filter { it.datetime?.isAfter(yesterday) == true })
uncacheContent(content);
}
}
fun getChannelCachePager(channelUrl: String): PlatformContentPager { fun getChannelCachePager(channelUrl: String): PlatformContentPager {
val validID = channelUrl.toSafeFileName(); val validID = channelUrl.toSafeFileName();
@@ -83,7 +99,7 @@ class ChannelContentCache {
val items = validStores.flatMap { it.getItems() } val items = validStores.flatMap { it.getItems() }
.sortedByDescending { it.datetime }; .sortedByDescending { it.datetime };
return DedupContentPager(PlatformContentPager(items, Math.min(150, items.size)), StatePlatform.instance.getEnabledClients().map { it.id }); return DedupContentPager(PlatformContentPager(items, Math.min(30, items.size)), StatePlatform.instance.getEnabledClients().map { it.id });
} }
fun uncacheContent(content: SerializedPlatformContent) { fun uncacheContent(content: SerializedPlatformContent) {
@@ -69,7 +69,7 @@ class ChromecastCastingDevice : CastingDevice {
return; return;
} }
Logger.i(FastCastCastingDevice.TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)"); Logger.i(TAG, "Start streaming (streamType: $streamType, contentType: $contentType, contentId: $contentId, resumePosition: $resumePosition, duration: $duration)");
time = resumePosition; time = resumePosition;
_streamType = streamType; _streamType = streamType;
@@ -4,6 +4,7 @@ import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.os.Looper import android.os.Looper
import com.futo.platformplayer.* import com.futo.platformplayer.*
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.api.http.server.ManagedHttpServer import com.futo.platformplayer.api.http.server.ManagedHttpServer
import com.futo.platformplayer.api.http.server.handlers.* import com.futo.platformplayer.api.http.server.handlers.*
import com.futo.platformplayer.api.media.models.streams.sources.* import com.futo.platformplayer.api.media.models.streams.sources.*
@@ -15,6 +16,7 @@ import com.futo.platformplayer.constructs.Event2
import com.futo.platformplayer.exceptions.UnsupportedCastException import com.futo.platformplayer.exceptions.UnsupportedCastException
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.models.CastingDeviceInfo import com.futo.platformplayer.models.CastingDeviceInfo
import com.futo.platformplayer.parsers.HLS
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.net.InetAddress import java.net.InetAddress
@@ -45,6 +47,7 @@ class StateCasting {
val onActiveDevicePlayChanged = Event1<Boolean>(); val onActiveDevicePlayChanged = Event1<Boolean>();
val onActiveDeviceTimeChanged = Event1<Double>(); val onActiveDeviceTimeChanged = Event1<Double>();
var activeDevice: CastingDevice? = null; var activeDevice: CastingDevice? = null;
private val _client = ManagedHttpClient();
val isCasting: Boolean get() = activeDevice != null; val isCasting: Boolean get() = activeDevice != null;
@@ -331,20 +334,25 @@ class StateCasting {
} }
if (sourceCount > 1) { if (sourceCount > 1) {
if (ad is AirPlayCastingDevice) {
StateApp.withContext(false) { context -> UIDialogs.toast(context, "AirPlay does not support DASH. Try ChromeCast or FastCast for casting this video."); };
ad.stopCasting();
return false;
}
if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) { if (videoSource is LocalVideoSource || audioSource is LocalAudioSource || subtitleSource is LocalSubtitleSource) {
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition); if (ad is AirPlayCastingDevice) {
Logger.i(TAG, "Casting as local HLS");
castLocalHls(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition);
} else {
Logger.i(TAG, "Casting as local DASH");
castLocalDash(video, videoSource as LocalVideoSource?, audioSource as LocalAudioSource?, subtitleSource as LocalSubtitleSource?, resumePosition);
}
} else { } else {
StateApp.instance.scope.launch(Dispatchers.IO) { StateApp.instance.scope.launch(Dispatchers.IO) {
try { try {
if (ad is FastCastCastingDevice) { if (ad is FastCastCastingDevice) {
Logger.i(TAG, "Casting as DASH direct");
castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); castDashDirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
} else if (ad is AirPlayCastingDevice) {
Logger.i(TAG, "Casting as HLS indirect");
castHlsIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
} else { } else {
Logger.i(TAG, "Casting as DASH indirect");
castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition); castDashIndirect(contentResolver, video, videoSource as IVideoUrlSource?, audioSource as IAudioUrlSource?, subtitleSource, resumePosition);
} }
} catch (e: Throwable) { } catch (e: Throwable) {
@@ -353,19 +361,35 @@ class StateCasting {
} }
} }
} else { } else {
if (videoSource is IVideoUrlSource) if (videoSource is IVideoUrlSource) {
ad.loadVideo("BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble()); Logger.i(TAG, "Casting as singular video");
else if(videoSource is IHLSManifestSource) ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.getVideoUrl(), resumePosition, video.duration.toDouble());
ad.loadVideo("BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble()); } else if (audioSource is IAudioUrlSource) {
else if (audioSource is IAudioUrlSource) Logger.i(TAG, "Casting as singular audio");
ad.loadVideo("BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble()); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.getAudioUrl(), resumePosition, video.duration.toDouble());
else if(audioSource is IHLSManifestAudioSource) } else if(videoSource is IHLSManifestSource) {
ad.loadVideo("BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble()); if (ad is ChromecastCastingDevice) {
else if (videoSource is LocalVideoSource) Logger.i(TAG, "Casting as proxied HLS");
castProxiedHls(video, videoSource.url, resumePosition);
} else {
Logger.i(TAG, "Casting as non-proxied HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", videoSource.container, videoSource.url, resumePosition, video.duration.toDouble());
}
} else if(audioSource is IHLSManifestAudioSource) {
if (ad is ChromecastCastingDevice) {
Logger.i(TAG, "Casting as proxied audio HLS");
castProxiedHls(video, audioSource.url, resumePosition);
} else {
Logger.i(TAG, "Casting as non-proxied audio HLS");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", audioSource.container, audioSource.url, resumePosition, video.duration.toDouble());
}
} else if (videoSource is LocalVideoSource) {
Logger.i(TAG, "Casting as local video");
castLocalVideo(video, videoSource, resumePosition); castLocalVideo(video, videoSource, resumePosition);
else if (audioSource is LocalAudioSource) } else if (audioSource is LocalAudioSource) {
Logger.i(TAG, "Casting as local audio");
castLocalAudio(video, audioSource, resumePosition); castLocalAudio(video, audioSource, resumePosition);
else { } else {
var str = listOf( var str = listOf(
if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null, if(videoSource != null) "Video: ${videoSource::class.java.simpleName}" else null,
if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null, if(audioSource != null) "Audio: ${audioSource::class.java.simpleName}" else null,
@@ -402,10 +426,18 @@ class StateCasting {
return true; return true;
} }
private fun castVideoIndirect() {
}
private fun castAudioIndirect() {
}
private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List<String> { private fun castLocalVideo(video: IPlatformVideoDetails, videoSource: LocalVideoSource, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress}:${_castServer.port}"; val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val videoPath = "/video-${id}" val videoPath = "/video-${id}"
val videoUrl = url + videoPath; val videoUrl = url + videoPath;
@@ -424,7 +456,7 @@ class StateCasting {
private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double) : List<String> { private fun castLocalAudio(video: IPlatformVideoDetails, audioSource: LocalAudioSource, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress}:${_castServer.port}"; val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val audioPath = "/audio-${id}" val audioPath = "/audio-${id}"
val audioUrl = url + audioPath; val audioUrl = url + audioPath;
@@ -440,11 +472,106 @@ class StateCasting {
return listOf(audioUrl); return listOf(audioUrl);
} }
private fun castLocalHls(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double): List<String> {
val ad = activeDevice ?: return listOf()
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}"
val id = UUID.randomUUID()
val hlsPath = "/hls-${id}"
val videoPath = "/video-${id}"
val audioPath = "/audio-${id}"
val subtitlePath = "/subtitle-${id}"
val hlsUrl = url + hlsPath
val videoUrl = url + videoPath
val audioUrl = url + audioPath
val subtitleUrl = url + subtitlePath
val mediaRenditions = arrayListOf<HLS.MediaRendition>()
val variantPlaylistReferences = arrayListOf<HLS.VariantPlaylistReference>()
if (videoSource != null) {
_castServer.addHandler(
HttpFileHandler("GET", videoPath, videoSource.container, videoSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
val duration = videoSource.duration
val videoVariantPlaylistPath = "/video-playlist-${id}"
val videoVariantPlaylistUrl = url + videoVariantPlaylistPath
val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl))
val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, videoVariantPlaylistSegments)
_castServer.addHandler(
HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo(
videoSource.bitrate, "${videoSource.width}x${videoSource.height}", videoSource.codec, null, null, if (audioSource != null) "audio" else null, if (subtitleSource != null) "subtitles" else null, null, null)))
}
if (audioSource != null) {
_castServer.addHandler(
HttpFileHandler("GET", audioPath, audioSource.container, audioSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown")
val audioVariantPlaylistPath = "/audio-playlist-${id}"
val audioVariantPlaylistUrl = url + audioVariantPlaylistPath
val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl))
val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, audioVariantPlaylistSegments)
_castServer.addHandler(
HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true))
}
if (subtitleSource != null) {
_castServer.addHandler(
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown")
val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}"
val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath
val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitleUrl))
val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, subtitleVariantPlaylistSegments)
_castServer.addHandler(
HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true))
}
val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true)
_castServer.addHandler(
HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castLocalHls")
Logger.i(TAG, "added new castLocalHls handlers (hlsPath: $hlsPath, videoPath: $videoPath, audioPath: $audioPath, subtitlePath: $subtitlePath).")
ad.loadVideo("BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble())
return listOf(hlsUrl, videoUrl, audioUrl, subtitleUrl)
}
private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List<String> { private fun castLocalDash(video: IPlatformVideoDetails, videoSource: LocalVideoSource?, audioSource: LocalAudioSource?, subtitleSource: LocalSubtitleSource?, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress}:${_castServer.port}"; val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val dashPath = "/dash-${id}" val dashPath = "/dash-${id}"
@@ -486,7 +613,7 @@ class StateCasting {
} }
if (subtitleSource != null) { if (subtitleSource != null) {
_castServer.addHandler( _castServer.addHandler(
HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath, true) HttpFileHandler("GET", subtitlePath, subtitleSource.format ?: "text/vtt", subtitleSource.filePath)
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast"); ).withTag("cast");
_castServer.addHandler( _castServer.addHandler(
@@ -505,7 +632,7 @@ class StateCasting {
private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> { private suspend fun castDashDirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress}:${_castServer.port}"; val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val subtitlePath = "/subtitle-${id}"; val subtitlePath = "/subtitle-${id}";
@@ -547,13 +674,277 @@ class StateCasting {
return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: ""); return listOf(videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "");
} }
private fun castProxiedHls(video: IPlatformVideoDetails, sourceUrl: String, resumePosition: Double): List<String> {
_castServer.removeAllHandlers("castProxiedHlsMaster")
val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID();
val hlsPath = "/hls-${id}"
val hlsUrl = url + hlsPath
Logger.i(TAG, "HLS url: $hlsUrl");
_castServer.addHandler(HttpFuntionHandler("GET", hlsPath) { masterContext ->
_castServer.removeAllHandlers("castProxiedHlsVariant")
val headers = masterContext.headers.clone()
headers["Content-Type"] = "application/vnd.apple.mpegurl";
val masterPlaylist = HLS.downloadAndParseMasterPlaylist(_client, sourceUrl)
val newVariantPlaylistRefs = arrayListOf<HLS.VariantPlaylistReference>()
val newMediaRenditions = arrayListOf<HLS.MediaRendition>()
val newMasterPlaylist = HLS.MasterPlaylist(newVariantPlaylistRefs, newMediaRenditions, masterPlaylist.sessionDataList, masterPlaylist.independentSegments)
for (variantPlaylistRef in masterPlaylist.variantPlaylistsRefs) {
val playlistId = UUID.randomUUID();
val newPlaylistPath = "/hls-playlist-${playlistId}"
val newPlaylistUrl = url + newPlaylistPath;
_castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, variantPlaylistRef.url)
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant")
newVariantPlaylistRefs.add(HLS.VariantPlaylistReference(
newPlaylistUrl,
variantPlaylistRef.streamInfo
))
}
for (mediaRendition in masterPlaylist.mediaRenditions) {
val playlistId = UUID.randomUUID()
var newPlaylistUrl: String? = null
if (mediaRendition.uri != null) {
val newPlaylistPath = "/hls-playlist-${playlistId}"
newPlaylistUrl = url + newPlaylistPath
_castServer.addHandler(HttpFuntionHandler("GET", newPlaylistPath) { vpContext ->
val vpHeaders = vpContext.headers.clone()
vpHeaders["Content-Type"] = "application/vnd.apple.mpegurl";
val variantPlaylist = HLS.downloadAndParseVariantPlaylist(_client, mediaRendition.uri)
val proxiedVariantPlaylist = proxyVariantPlaylist(url, playlistId, variantPlaylist, video.isLive)
val proxiedVariantPlaylist_m3u8 = proxiedVariantPlaylist.buildM3U8()
vpContext.respondCode(200, vpHeaders, proxiedVariantPlaylist_m3u8);
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsVariant")
}
newMediaRenditions.add(HLS.MediaRendition(
mediaRendition.type,
newPlaylistUrl,
mediaRendition.groupID,
mediaRendition.language,
mediaRendition.name,
mediaRendition.isDefault,
mediaRendition.isAutoSelect,
mediaRendition.isForced
))
}
masterContext.respondCode(200, headers, newMasterPlaylist.buildM3U8());
}.withHeader("Access-Control-Allow-Origin", "*"), true).withTag("castProxiedHlsMaster")
Logger.i(TAG, "added new castHlsIndirect handlers (hlsPath: $hlsPath).");
//ChromeCast is sometimes funky with resume position 0
val hackfixResumePosition = if (ad is ChromecastCastingDevice && !video.isLive && resumePosition == 0.0) 1.0 else resumePosition;
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, hackfixResumePosition, video.duration.toDouble());
return listOf(hlsUrl);
}
private fun proxyVariantPlaylist(url: String, playlistId: UUID, variantPlaylist: HLS.VariantPlaylist, isLive: Boolean, proxySegments: Boolean = true): HLS.VariantPlaylist {
val newSegments = arrayListOf<HLS.Segment>()
if (proxySegments) {
variantPlaylist.segments.forEachIndexed { index, segment ->
val sequenceNumber = variantPlaylist.mediaSequence + index.toLong()
newSegments.add(proxySegment(url, playlistId, segment, sequenceNumber))
}
} else {
newSegments.addAll(variantPlaylist.segments)
}
return HLS.VariantPlaylist(
variantPlaylist.version,
variantPlaylist.targetDuration,
variantPlaylist.mediaSequence,
variantPlaylist.discontinuitySequence,
variantPlaylist.programDateTime,
variantPlaylist.playlistType,
newSegments
)
}
private fun proxySegment(url: String, playlistId: UUID, segment: HLS.Segment, index: Long): HLS.Segment {
if (segment is HLS.MediaSegment) {
val newSegmentPath = "/hls-playlist-${playlistId}-segment-${index}"
val newSegmentUrl = url + newSegmentPath;
if (_castServer.getHandler("GET", newSegmentPath) == null) {
_castServer.addHandler(
HttpProxyHandler("GET", newSegmentPath, segment.uri, true)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castProxiedHlsVariant")
}
return HLS.MediaSegment(
segment.duration,
newSegmentUrl
)
} else {
return segment
}
}
private suspend fun castHlsIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf();
val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
val id = UUID.randomUUID();
val hlsPath = "/hls-${id}"
val hlsUrl = url + hlsPath;
Logger.i(TAG, "HLS url: $hlsUrl");
val mediaRenditions = arrayListOf<HLS.MediaRendition>()
val variantPlaylistReferences = arrayListOf<HLS.VariantPlaylistReference>()
if (audioSource != null) {
val audioPath = "/audio-${id}"
val audioUrl = url + audioPath
val duration = audioSource.duration ?: videoSource?.duration ?: throw Exception("Duration unknown")
val audioVariantPlaylistPath = "/audio-playlist-${id}"
val audioVariantPlaylistUrl = url + audioVariantPlaylistPath
val audioVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), audioUrl))
val audioVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, audioVariantPlaylistSegments)
_castServer.addHandler(
HttpConstantHandler("GET", audioVariantPlaylistPath, audioVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(audioVariantPlaylistPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
mediaRenditions.add(HLS.MediaRendition("AUDIO", audioVariantPlaylistUrl, "audio", "en", "english", true, true, true))
_castServer.addHandler(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(audioPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
}
val subtitlesUri = if (subtitleSource != null) withContext(Dispatchers.IO) {
return@withContext subtitleSource.getSubtitlesURI();
} else null;
var subtitlesUrl: String? = null;
if (subtitlesUri != null) {
val subtitlePath = "/subtitles-${id}"
if(subtitlesUri.scheme == "file") {
var content: String? = null;
val inputStream = contentResolver.openInputStream(subtitlesUri);
inputStream?.use { stream ->
val reader = stream.bufferedReader();
content = reader.use { it.readText() };
}
if (content != null) {
_castServer.addHandler(
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(subtitlePath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
}
subtitlesUrl = url + subtitlePath;
} else {
subtitlesUrl = subtitlesUri.toString();
}
}
if (subtitlesUrl != null) {
val duration = videoSource?.duration ?: audioSource?.duration ?: throw Exception("Duration unknown")
val subtitleVariantPlaylistPath = "/subtitle-playlist-${id}"
val subtitleVariantPlaylistUrl = url + subtitleVariantPlaylistPath
val subtitleVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), subtitlesUrl))
val subtitleVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, subtitleVariantPlaylistSegments)
_castServer.addHandler(
HttpConstantHandler("GET", subtitleVariantPlaylistPath, subtitleVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(subtitleVariantPlaylistPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
mediaRenditions.add(HLS.MediaRendition("SUBTITLES", subtitleVariantPlaylistUrl, "subtitles", "en", "english", true, true, true))
}
if (videoSource != null) {
val videoPath = "/video-${id}"
val videoUrl = url + videoPath
val duration = videoSource.duration
val videoVariantPlaylistPath = "/video-playlist-${id}"
val videoVariantPlaylistUrl = url + videoVariantPlaylistPath
val videoVariantPlaylistSegments = listOf(HLS.MediaSegment(duration.toDouble(), videoUrl))
val videoVariantPlaylist = HLS.VariantPlaylist(3, duration.toInt(), 0, 0, null, null, videoVariantPlaylistSegments)
_castServer.addHandler(
HttpConstantHandler("GET", videoVariantPlaylistPath, videoVariantPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(videoVariantPlaylistPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
variantPlaylistReferences.add(HLS.VariantPlaylistReference(videoVariantPlaylistUrl, HLS.StreamInfo(
videoSource.bitrate ?: 0,
"${videoSource.width}x${videoSource.height}",
videoSource.codec,
null,
null,
if (audioSource != null) "audio" else null,
if (subtitleSource != null) "subtitles" else null,
null, null)))
_castServer.addHandler(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectVariant");
_castServer.addHandler(HttpOptionsAllowHandler(videoPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
}
val masterPlaylist = HLS.MasterPlaylist(variantPlaylistReferences, mediaRenditions, listOf(), true)
_castServer.addHandler(
HttpConstantHandler("GET", hlsPath, masterPlaylist.buildM3U8(),
"application/vnd.apple.mpegurl")
.withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("castHlsIndirectMaster")
_castServer.addHandler(HttpOptionsAllowHandler(hlsPath).withHeader("Access-Control-Allow-Origin", "*")).withTag("castHlsIndirectVariant");
Logger.i(TAG, "added new castHls handlers (hlsPath: $hlsPath).");
ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/vnd.apple.mpegurl", hlsUrl, resumePosition, video.duration.toDouble());
return listOf(hlsUrl, videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
}
private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> { private suspend fun castDashIndirect(contentResolver: ContentResolver, video: IPlatformVideoDetails, videoSource: IVideoUrlSource?, audioSource: IAudioUrlSource?, subtitleSource: ISubtitleSource?, resumePosition: Double) : List<String> {
val ad = activeDevice ?: return listOf(); val ad = activeDevice ?: return listOf();
val proxyStreams = ad !is FastCastCastingDevice; val proxyStreams = ad !is FastCastCastingDevice;
val url = "http://${ad.localAddress}:${_castServer.port}"; val url = "http://${ad.localAddress.toString().trim('/')}:${_castServer.port}";
Logger.i(TAG, "DASH url: $url");
val id = UUID.randomUUID(); val id = UUID.randomUUID();
val dashPath = "/dash-${id}" val dashPath = "/dash-${id}"
@@ -562,6 +953,8 @@ class StateCasting {
val subtitlePath = "/subtitle-${id}" val subtitlePath = "/subtitle-${id}"
val dashUrl = url + dashPath; val dashUrl = url + dashPath;
Logger.i(TAG, "DASH url: $dashUrl");
val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl(); val videoUrl = if(proxyStreams) url + videoPath else videoSource?.getVideoUrl();
val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl(); val audioUrl = if(proxyStreams) url + audioPath else audioSource?.getAudioUrl();
@@ -587,6 +980,7 @@ class StateCasting {
HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt") HttpConstantHandler("GET", subtitlePath, content!!, subtitleSource?.format ?: "text/vtt")
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast"); ).withTag("cast");
_castServer.addHandler(HttpOptionsAllowHandler(subtitlePath).withHeader("Access-Control-Allow-Origin", "*")).withTag("cast");
} }
subtitlesUrl = url + subtitlePath; subtitlesUrl = url + subtitlePath;
@@ -600,33 +994,37 @@ class StateCasting {
"application/dash+xml") "application/dash+xml")
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast"); ).withTag("cast");
_castServer.addHandler(
HttpOptionsAllowHandler(dashPath)
.withHeader("Access-Control-Allow-Origin", "*")
).withTag("cast");
if (videoSource != null) { if (videoSource != null) {
_castServer.addHandler( _castServer.addHandler(
HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl()) HttpProxyHandler("GET", videoPath, videoSource.getVideoUrl(), true)
.withInjectedHost() .withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast"); ).withTag("cast");
_castServer.addHandler( _castServer.addHandler(
HttpOptionsAllowHandler(videoPath) HttpOptionsAllowHandler(videoPath)
.withHeader("Access-Control-Allow-Origin", "*") .withHeader("Access-Control-Allow-Origin", "*")
.withHeader("Connection", "keep-alive")) ).withTag("cast");
.withTag("cast");
} }
if (audioSource != null) { if (audioSource != null) {
_castServer.addHandler( _castServer.addHandler(
HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl()) HttpProxyHandler("GET", audioPath, audioSource.getAudioUrl(), true)
.withInjectedHost() .withInjectedHost()
.withHeader("Access-Control-Allow-Origin", "*"), true .withHeader("Access-Control-Allow-Origin", "*"), true
).withTag("cast"); ).withTag("cast");
_castServer.addHandler( _castServer.addHandler(
HttpOptionsAllowHandler(audioPath) HttpOptionsAllowHandler(audioPath)
.withHeader("Access-Control-Allow-Origin", "*") .withHeader("Access-Control-Allow-Origin", "*")
.withHeader("Connection", "keep-alivcontexte")) )
.withTag("cast"); .withTag("cast");
} }
Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath)."); Logger.i(TAG, "added new castDash handlers (dashPath: $dashPath, videoPath: $videoPath, audioPath: $audioPath).");
ad.loadVideo("BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble()); ad.loadVideo(if (video.isLive) "LIVE" else "BUFFERED", "application/dash+xml", dashUrl, resumePosition, video.duration.toDouble());
return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString()); return listOf(dashUrl, videoUrl ?: "", audioUrl ?: "", subtitlesUrl ?: "", videoSource?.getVideoUrl() ?: "", audioSource?.getAudioUrl() ?: "", subtitlesUri.toString());
} }
@@ -24,6 +24,7 @@ import com.google.gson.JsonArray
import com.google.gson.JsonParser import com.google.gson.JsonParser
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.lang.reflect.InvocationTargetException
import java.util.UUID import java.util.UUID
import kotlin.reflect.jvm.jvmErasure import kotlin.reflect.jvm.jvmErasure
@@ -185,7 +186,11 @@ class DeveloperEndpoints(private val context: Context) {
val config = context.readContentJson<SourcePluginConfig>() val config = context.readContentJson<SourcePluginConfig>()
try { try {
_testPluginVariables.clear(); _testPluginVariables.clear();
_testPlugin = V8Plugin(StateApp.instance.context, config);
val client = JSHttpClient(null, null, null, config);
val clientAuth = JSHttpClient(null, null, null, config);
_testPlugin = V8Plugin(StateApp.instance.context, config, null, client, clientAuth);
context.respondJson(200, testPluginOrThrow.getPackageVariables()); context.respondJson(200, testPluginOrThrow.getPackageVariables());
} }
catch(ex: Throwable) { catch(ex: Throwable) {
@@ -235,7 +240,7 @@ class DeveloperEndpoints(private val context: Context) {
} }
LoginActivity.showLogin(StateApp.instance.context, config) { LoginActivity.showLogin(StateApp.instance.context, config) {
_testPluginVariables.clear(); _testPluginVariables.clear();
_testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null), JSHttpClient(null, it)); _testPlugin = V8Plugin(StateApp.instance.context, config, null, JSHttpClient(null, null, null, config), JSHttpClient(null, it, null, config));
}; };
context.respondCode(200, "Login started"); context.respondCode(200, "Login started");
@@ -287,7 +292,6 @@ class DeveloperEndpoints(private val context: Context) {
@HttpPOST("/plugin/remoteCall") @HttpPOST("/plugin/remoteCall")
fun pluginRemoteCall(context: HttpContext) { fun pluginRemoteCall(context: HttpContext) {
try { try {
val parameters = context.readContentString();
val objId = context.query.get("id") val objId = context.query.get("id")
val method = context.query.get("method") val method = context.query.get("method")
@@ -299,16 +303,24 @@ class DeveloperEndpoints(private val context: Context) {
context.respondCode(400, "Missing method"); context.respondCode(400, "Missing method");
return; return;
} }
if(method != "isLoggedIn")
Logger.i(TAG, "Remote Call [${objId}].${method}(...)");
val parameters = context.readContentString();
val remoteObj = getRemoteObject(objId); val remoteObj = getRemoteObject(objId);
val paras = JsonParser.parseString(parameters); val paras = JsonParser.parseString(parameters);
if(!paras.isJsonArray) if(!paras.isJsonArray)
throw IllegalArgumentException("Expected json array as body"); throw IllegalArgumentException("Expected json array as body");
if(method != "isLoggedIn")
Logger.i(TAG, "Remote Call [${objId}].${method}(...)");
val callResult = remoteObj.call(method, paras as JsonArray); val callResult = remoteObj.call(method, paras as JsonArray);
val json = wrapRemoteResult(callResult, false); val json = wrapRemoteResult(callResult, false);
context.respondCode(200, json, "application/json"); context.respondCode(200, json, "application/json");
} }
catch(invocation: InvocationTargetException) {
val innerException = invocation.targetException;
Logger.e("DeveloperEndpoints", innerException.message, innerException);
context.respondCode(500, innerException::class.simpleName + ":" + innerException.message ?: "", "text/plain")
}
catch(ilEx: IllegalArgumentException) { catch(ilEx: IllegalArgumentException) {
if(ilEx.message?.contains("does not exist") ?: false) { if(ilEx.message?.contains("does not exist") ?: false) {
context.respondCode(400, ilEx.message ?: "", "text/plain"); context.respondCode(400, ilEx.message ?: "", "text/plain");
@@ -95,6 +95,8 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
_buttonUpdate.visibility = Button.GONE; _buttonUpdate.visibility = Button.GONE;
setCancelable(false); setCancelable(false);
setCanceledOnTouchOutside(false); setCanceledOnTouchOutside(false);
Logger.i(TAG, "Keep screen on set update")
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
_text.text = context.resources.getText(R.string.downloading_update); _text.text = context.resources.getText(R.string.downloading_update);
@@ -178,6 +180,7 @@ class AutoUpdateDialog(context: Context?) : AlertDialog(context) {
} }
} finally { } finally {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
Logger.i(TAG, "Keep screen on unset install")
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} }
} }
@@ -20,6 +20,7 @@ import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComm
import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.selectBestImage import com.futo.platformplayer.selectBestImage
import com.futo.platformplayer.states.StateApp import com.futo.platformplayer.states.StateApp
@@ -85,6 +86,11 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
return@setOnClickListener; return@setOnClickListener;
} }
if (_editComment.text.isBlank()) {
UIDialogs.toast(context, "Comment should not be blank.");
return@setOnClickListener;
}
val comment = _editComment.text.toString(); val comment = _editComment.text.toString();
val processHandle = StatePolycentric.instance.processHandle!! val processHandle = StatePolycentric.instance.processHandle!!
val eventPointer = processHandle.post(comment, null, ref) val eventPointer = processHandle.post(comment, null, ref)
@@ -92,7 +98,7 @@ class CommentDialog(context: Context?, val contextUrl: String, val ref: Protocol
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
Logger.i(TAG, "Started backfill"); Logger.i(TAG, "Started backfill");
processHandle.fullyBackfillServers() processHandle.fullyBackfillServersAnnounceExceptions()
Logger.i(TAG, "Finished backfill"); Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers.", e); Logger.e(TAG, "Failed to backfill servers.", e);
@@ -134,6 +134,8 @@ class ImportDialog : AlertDialog {
setCancelable(false); setCancelable(false);
setCanceledOnTouchOutside(false); setCanceledOnTouchOutside(false);
Logger.i(TAG, "Keep screen on set import")
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
_updateSpinner.drawable?.assume<Animatable>()?.start(); _updateSpinner.drawable?.assume<Animatable>()?.start();
@@ -201,6 +203,7 @@ class ImportDialog : AlertDialog {
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to update import UI.", e) Logger.e(TAG, "Failed to update import UI.", e)
} finally { } finally {
Logger.i(TAG, "Keep screen on unset update")
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} }
} }
@@ -144,6 +144,7 @@ class MigrateDialog : AlertDialog {
setCancelable(false); setCancelable(false);
setCanceledOnTouchOutside(false); setCanceledOnTouchOutside(false);
Logger.i(TAG, "Keep screen on set restore")
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
_updateSpinner.drawable?.assume<Animatable>()?.start(); _updateSpinner.drawable?.assume<Animatable>()?.start();
@@ -214,6 +215,7 @@ class MigrateDialog : AlertDialog {
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to update import UI.", e) Logger.e(TAG, "Failed to update import UI.", e)
} finally { } finally {
Logger.i(TAG, "Keep screen on unset restore")
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} }
} }
@@ -1,13 +1,18 @@
package com.futo.platformplayer.downloads package com.futo.platformplayer.downloads
import android.content.Context
import android.net.Uri
import android.os.Environment import android.os.Environment
import androidx.documentfile.provider.DocumentFile
import com.arthenica.ffmpegkit.* import com.arthenica.ffmpegkit.*
import com.futo.platformplayer.api.media.models.streams.sources.* import com.futo.platformplayer.api.media.models.streams.sources.*
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.toHumanBitrate import com.futo.platformplayer.toHumanBitrate
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.* import java.io.*
import java.util.UUID
import java.util.concurrent.CancellationException import java.util.concurrent.CancellationException
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@@ -43,7 +48,7 @@ class VideoExport {
this.subtitleSource = subtitleSource; this.subtitleSource = subtitleSource;
} }
suspend fun export(onProgress: ((Double) -> Unit)? = null): File = coroutineScope { suspend fun export(context: Context, onProgress: ((Double) -> Unit)? = null): DocumentFile = coroutineScope {
if(isCancelled) throw CancellationException("Export got cancelled"); if(isCancelled) throw CancellationException("Export got cancelled");
val v = videoSource; val v = videoSource;
@@ -55,34 +60,47 @@ class VideoExport {
if (a != null) sourceCount++; if (a != null) sourceCount++;
if (s != null) sourceCount++; if (s != null) sourceCount++;
var outputFile: File? = null; val outputFile: DocumentFile?;
val moviesRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); val downloadRoot = StateApp.instance.getExternalDownloadDirectory(context) ?: throw Exception("External download directory is not set");
val musicRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC);
val moviesGrayjay = File(moviesRoot, "Grayjay");
val musicGrayjay = File(musicRoot, "Grayjay");
if(!moviesGrayjay.exists())
moviesGrayjay.mkdirs();
if(!musicGrayjay.exists())
musicGrayjay.mkdirs();
if (sourceCount > 1) { if (sourceCount > 1) {
val outputFileName = toSafeFileName(videoLocal.name) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container); val outputFileName = toSafeFileName(videoLocal.name) + ".mp4"// + VideoDownload.videoContainerToExtension(v.container);
val f = File(moviesGrayjay, outputFileName); val f = downloadRoot.createFile("video/mp4", outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Combining video and audio through FFMPEG."); Logger.i(TAG, "Combining video and audio through FFMPEG.");
combine(a?.filePath, v?.filePath, s?.filePath, f.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) }; val tempFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4");
try {
combine(a?.filePath, v?.filePath, s?.filePath, tempFile.absolutePath, videoLocal.duration.toDouble()) { progress -> onProgress?.invoke(progress) };
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
copy(tempFile.absolutePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
} finally {
tempFile.delete();
}
outputFile = f; outputFile = f;
} else if (v != null) { } else if (v != null) {
val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.videoContainerToExtension(v.container); val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.videoContainerToExtension(v.container);
val f = File(moviesGrayjay, outputFileName); val f = downloadRoot.createFile(v.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying video."); Logger.i(TAG, "Copying video.");
copy(v.filePath, f.absolutePath) { progress -> onProgress?.invoke(progress) };
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
copy(v.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
outputFile = f; outputFile = f;
} else if (a != null) { } else if (a != null) {
val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.audioContainerToExtension(a.container); val outputFileName = toSafeFileName(videoLocal.name) + "." + VideoDownload.audioContainerToExtension(a.container);
val f = File(musicGrayjay, outputFileName); val f = downloadRoot.createFile(a.container, outputFileName)
?: throw Exception("Failed to create file in external directory.");
Logger.i(TAG, "Copying audio."); Logger.i(TAG, "Copying audio.");
copy(a.filePath, f.absolutePath) { progress -> onProgress?.invoke(progress) };
context.contentResolver.openOutputStream(f.uri)?.use { outputStream ->
copy(a.filePath, outputStream) { progress -> onProgress?.invoke(progress) };
}
outputFile = f; outputFile = f;
} else { } else {
throw Exception("Cannot export when no audio or video source is set."); throw Exception("Cannot export when no audio or video source is set.");
@@ -179,10 +197,9 @@ class VideoExport {
} }
} }
private suspend fun copy(fromPath: String, toPath: String, bufferSize: Int = 8192, onProgress: ((Double) -> Unit)? = null) { private suspend fun copy(fromPath: String, outputStream: OutputStream, bufferSize: Int = 8192, onProgress: ((Double) -> Unit)? = null) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
var inputStream: FileInputStream? = null var inputStream: FileInputStream? = null
var outputStream: FileOutputStream? = null
try { try {
val srcFile = File(fromPath) val srcFile = File(fromPath)
@@ -190,17 +207,7 @@ class VideoExport {
throw IOException("Source file not found.") throw IOException("Source file not found.")
} }
val dstFile = File(toPath)
val parentDir = dstFile.parentFile ?: throw IOException("Non existent parent dir.")
if (!parentDir.exists()) {
if (!parentDir.mkdirs()) {
throw IOException("Failed to create destination directory.")
}
}
inputStream = FileInputStream(srcFile) inputStream = FileInputStream(srcFile)
outputStream = FileOutputStream(dstFile)
val buffer = ByteArray(bufferSize) val buffer = ByteArray(bufferSize)
val totalBytes = srcFile.length() val totalBytes = srcFile.length()
@@ -221,7 +228,6 @@ class VideoExport {
throw IOException("Error occurred while copying file: ${e.message}", e) throw IOException("Error occurred while copying file: ${e.message}", e)
} finally { } finally {
inputStream?.close() inputStream?.close()
outputStream?.close()
} }
} }
} }
@@ -0,0 +1,8 @@
package com.futo.platformplayer.encryption
class GEncryptionProvider {
companion object {
val instance: GEncryptionProviderV1 = GEncryptionProviderV1.instance;
val version = 1;
}
}
@@ -8,9 +8,8 @@ import java.security.KeyStore
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.KeyGenerator import javax.crypto.KeyGenerator
import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
class EncryptionProvider { class GEncryptionProviderV0 {
private val _keyStore: KeyStore; private val _keyStore: KeyStore;
private val secretKey: Key? get() = _keyStore.getKey(KEY_ALIAS, null); private val secretKey: Key? get() = _keyStore.getKey(KEY_ALIAS, null);
@@ -25,45 +24,43 @@ class EncryptionProvider {
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(false) .setRandomizedEncryptionRequired(false)
.build()); .build());
keyGenerator.generateKey(); keyGenerator.generateKey();
} }
} }
fun encrypt(decrypted: String, password: String? = null): String { fun encrypt(decrypted: String): String {
val encodedBytes = encrypt(decrypted.toByteArray(), password); val encodedBytes = encrypt(decrypted.toByteArray());
val encrypted = Base64.encodeToString(encodedBytes, Base64.DEFAULT); val encrypted = Base64.encodeToString(encodedBytes, Base64.DEFAULT);
return encrypted; return encrypted;
} }
fun encrypt(decrypted: ByteArray, password: String? = null): ByteArray { fun encrypt(decrypted: ByteArray): ByteArray {
val c: Cipher = Cipher.getInstance(AES_MODE); val c: Cipher = Cipher.getInstance(AES_MODE);
val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES"); c.init(Cipher.ENCRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
c.init(Cipher.ENCRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV));
val encodedBytes: ByteArray = c.doFinal(decrypted); val encodedBytes: ByteArray = c.doFinal(decrypted);
return encodedBytes; return encodedBytes;
} }
fun decrypt(encrypted: String, password: String? = null): String { fun decrypt(encrypted: String): String {
val c = Cipher.getInstance(AES_MODE); val c = Cipher.getInstance(AES_MODE);
val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES"); c.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
c.init(Cipher.DECRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV));
val decrypted = String(c.doFinal(Base64.decode(encrypted, Base64.DEFAULT))); val decrypted = String(c.doFinal(Base64.decode(encrypted, Base64.DEFAULT)));
return decrypted; return decrypted;
} }
fun decrypt(encrypted: ByteArray, password: String? = null): ByteArray { fun decrypt(encrypted: ByteArray): ByteArray {
val c = Cipher.getInstance(AES_MODE); val c = Cipher.getInstance(AES_MODE);
val keyToUse = if(password == null) secretKey else SecretKeySpec(password.toByteArray(), "AES"); c.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
c.init(Cipher.DECRYPT_MODE, keyToUse, GCMParameterSpec(128, FIXED_IV));
return c.doFinal(encrypted); return c.doFinal(encrypted);
} }
companion object { companion object {
val instance: EncryptionProvider = EncryptionProvider(); val instance: GEncryptionProviderV0 = GEncryptionProviderV0();
private val FIXED_IV = byteArrayOf(12, 43, 127, 2, 99, 22, 6, 78, 24, 53, 8, 101); private val FIXED_IV = byteArrayOf(12, 43, 127, 2, 99, 22, 6, 78, 24, 53, 8, 101);
private const val AndroidKeyStore = "AndroidKeyStore"; private const val AndroidKeyStore = "AndroidKeyStore";
private const val KEY_ALIAS = "FUTOMedia_Key"; private const val KEY_ALIAS = "FUTOMedia_Key";
private const val AES_MODE = "AES/GCM/NoPadding"; private const val AES_MODE = "AES/GCM/NoPadding";
private val TAG = "EncryptionProvider"; private const val TAG_LENGTH = 128
private val TAG = "GEncryptionProviderV0";
} }
} }
@@ -0,0 +1,76 @@
package com.futo.platformplayer.encryption
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.Key
import java.security.KeyStore
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.spec.GCMParameterSpec
class GEncryptionProviderV1 {
private val _keyStore: KeyStore;
private val secretKey: Key? get() = _keyStore.getKey(KEY_ALIAS, null);
constructor() {
_keyStore = KeyStore.getInstance(AndroidKeyStore);
_keyStore.load(null);
if (!_keyStore.containsAlias(KEY_ALIAS)) {
val keyGenerator: KeyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, AndroidKeyStore)
keyGenerator.init(KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(false)
.build());
keyGenerator.generateKey();
}
}
fun encrypt(decrypted: String): String {
val encrypted = encrypt(decrypted.toByteArray());
val encoded = Base64.encodeToString(encrypted, Base64.DEFAULT);
return encoded;
}
fun encrypt(decrypted: ByteArray): ByteArray {
val ivBytes = generateIv()
val c: Cipher = Cipher.getInstance(AES_MODE);
c.init(Cipher.ENCRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, ivBytes));
val encodedBytes: ByteArray = c.doFinal(decrypted);
return ivBytes + encodedBytes;
}
fun decrypt(data: String): String {
val bytes = Base64.decode(data, Base64.DEFAULT)
return String(decrypt(bytes));
}
fun decrypt(bytes: ByteArray): ByteArray {
val encrypted = bytes.sliceArray(IntRange(IV_SIZE, bytes.size - 1))
val ivBytes = bytes.sliceArray(IntRange(0, IV_SIZE - 1))
val c = Cipher.getInstance(AES_MODE);
c.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(TAG_LENGTH, ivBytes));
return c.doFinal(encrypted);
}
private fun generateIv(): ByteArray {
val r = SecureRandom()
val ivBytes = ByteArray(IV_SIZE)
r.nextBytes(ivBytes)
return ivBytes
}
companion object {
val instance: GEncryptionProviderV1 = GEncryptionProviderV1();
private const val AndroidKeyStore = "AndroidKeyStore";
private const val KEY_ALIAS = "FUTOMedia_Key";
private const val AES_MODE = "AES/GCM/NoPadding";
private const val IV_SIZE = 12;
private const val TAG_LENGTH = 128
private val TAG = "GEncryptionProviderV1";
}
}
@@ -0,0 +1,8 @@
package com.futo.platformplayer.encryption
class GPasswordEncryptionProvider {
companion object {
val version = 1;
val instance = GPasswordEncryptionProviderV1.instance;
}
}
@@ -0,0 +1,45 @@
package com.futo.platformplayer.encryption
import android.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
class GPasswordEncryptionProviderV0 {
private val _key: SecretKeySpec;
constructor(password: String) {
_key = SecretKeySpec(password.toByteArray(), "AES");
}
fun encrypt(decrypted: String): String {
val encodedBytes = encrypt(decrypted.toByteArray());
val encrypted = Base64.encodeToString(encodedBytes, Base64.DEFAULT);
return encrypted;
}
fun encrypt(decrypted: ByteArray): ByteArray {
val c: Cipher = Cipher.getInstance(AES_MODE);
c.init(Cipher.ENCRYPT_MODE, _key, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
val encodedBytes: ByteArray = c.doFinal(decrypted);
return encodedBytes;
}
fun decrypt(encrypted: String): String {
val c = Cipher.getInstance(AES_MODE);
c.init(Cipher.DECRYPT_MODE, _key, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
val decrypted = String(c.doFinal(Base64.decode(encrypted, Base64.DEFAULT)));
return decrypted;
}
fun decrypt(encrypted: ByteArray): ByteArray {
val c = Cipher.getInstance(AES_MODE);
c.init(Cipher.DECRYPT_MODE, _key, GCMParameterSpec(TAG_LENGTH, FIXED_IV));
return c.doFinal(encrypted);
}
companion object {
private val FIXED_IV = byteArrayOf(12, 43, 127, 2, 99, 22, 6, 78, 24, 53, 8, 101);
private const val TAG_LENGTH = 128
private const val AES_MODE = "AES/GCM/NoPadding";
private val TAG = "GPasswordEncryptionProviderV0";
}
}
@@ -0,0 +1,75 @@
package com.futo.platformplayer.encryption
import android.util.Base64
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
class GPasswordEncryptionProviderV1 {
fun encrypt(decrypted: String, password: String): String {
val encrypted = encrypt(decrypted.toByteArray(), password);
val encoded = Base64.encodeToString(encrypted, Base64.DEFAULT);
return encoded;
}
fun encrypt(decrypted: ByteArray, password: String): ByteArray {
val saltBytes = generateSalt()
val ivBytes = generateIv()
val c: Cipher = Cipher.getInstance(AES_MODE);
val key = deriveKeyFromPassword(password, saltBytes)
c.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_LENGTH, ivBytes));
val encodedBytes: ByteArray = c.doFinal(decrypted);
return saltBytes + ivBytes + encodedBytes;
}
fun decrypt(data: String, password: String): String {
val bytes = Base64.decode(data, Base64.DEFAULT)
return String(decrypt(bytes, password));
}
fun decrypt(bytes: ByteArray, password: String): ByteArray {
val encrypted = bytes.sliceArray(IntRange(SALT_SIZE + IV_SIZE, bytes.size - 1))
val ivBytes = bytes.sliceArray(IntRange(SALT_SIZE, SALT_SIZE + IV_SIZE - 1))
val saltBytes = bytes.sliceArray(IntRange(0, SALT_SIZE - 1))
val key = deriveKeyFromPassword(password, saltBytes)
val c = Cipher.getInstance(AES_MODE);
c.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_LENGTH, ivBytes));
return c.doFinal(encrypted);
}
private fun deriveKeyFromPassword(password: String, salt: ByteArray): SecretKeySpec {
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
val spec = PBEKeySpec(password.toCharArray(), salt, ITERATION_COUNT, KEY_LENGTH)
val tmp = factory.generateSecret(spec)
return SecretKeySpec(tmp.encoded, "AES")
}
private fun generateSalt(): ByteArray {
val random = SecureRandom()
val salt = ByteArray(SALT_SIZE)
random.nextBytes(salt)
return salt
}
private fun generateIv(): ByteArray {
val r = SecureRandom()
val ivBytes = ByteArray(IV_SIZE)
r.nextBytes(ivBytes)
return ivBytes
}
companion object {
val instance = GPasswordEncryptionProviderV1();
private const val AES_MODE = "AES/GCM/NoPadding";
private const val IV_SIZE = 12
private const val SALT_SIZE = 16
private const val ITERATION_COUNT = 2 * 65536
private const val KEY_LENGTH = 256
private const val TAG_LENGTH = 128
private val TAG = "GPasswordEncryptionProviderV1";
}
}
@@ -4,7 +4,6 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -12,8 +11,8 @@ import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.ContentType import com.futo.platformplayer.api.media.models.contents.ContentType
@@ -36,7 +35,7 @@ import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.states.StatePolycentric import com.futo.platformplayer.states.StatePolycentric
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -56,9 +55,11 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
val onContentClicked = Event2<IPlatformContent, Long>(); val onContentClicked = Event2<IPlatformContent, Long>();
val onContentUrlClicked = Event2<String, ContentType>(); val onContentUrlClicked = Event2<String, ContentType>();
val onUrlClicked = Event1<String>();
val onChannelClicked = Event1<PlatformAuthorLink>(); val onChannelClicked = Event1<PlatformAuthorLink>();
val onAddToClicked = Event1<IPlatformContent>(); val onAddToClicked = Event1<IPlatformContent>();
val onAddToQueueClicked = Event1<IPlatformContent>(); val onAddToQueueClicked = Event1<IPlatformContent>();
val onLongPress = Event1<IPlatformContent>();
private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> { private fun getContentPager(channel: IPlatformChannel): IPager<IPlatformContent> {
Logger.i(TAG, "getContentPager"); Logger.i(TAG, "getContentPager");
@@ -75,15 +76,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
} }
private val _taskLoadVideos = TaskHandler<IPlatformChannel, IPager<IPlatformContent>>({lifecycleScope}, { private val _taskLoadVideos = TaskHandler<IPlatformChannel, IPager<IPlatformContent>>({lifecycleScope}, {
return@TaskHandler getContentPager(it); val livePager = getContentPager(it);
return@TaskHandler if(_channel?.let { StateSubscriptions.instance.isSubscribed(it) } == true)
ChannelContentCache.cachePagerResults(lifecycleScope, livePager);
else livePager;
}).success { livePager -> }).success { livePager ->
setLoading(false); setLoading(false);
val pager = if(_channel?.let { StateSubscriptions.instance.isSubscribed(it) } == true) setPager(livePager);
ChannelContentCache.cachePagerResults(lifecycleScope, livePager);
else livePager;
setPager(pager);
} }
.exception<ScriptCaptchaRequiredException> { } .exception<ScriptCaptchaRequiredException> { }
.exception<Throwable> { .exception<Throwable> {
@@ -153,12 +153,14 @@ class ChannelContentsFragment : Fragment(), IChannelTabFragment {
_recyclerResults = view.findViewById(R.id.recycler_videos); _recyclerResults = view.findViewById(R.id.recycler_videos);
_adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results).apply { _adapterResults = PreviewContentListAdapter(view.context, FeedStyle.THUMBNAIL, _results, null, Settings.instance.channel.progressBar).apply {
this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit); this.onContentUrlClicked.subscribe(this@ChannelContentsFragment.onContentUrlClicked::emit);
this.onUrlClicked.subscribe(this@ChannelContentsFragment.onUrlClicked::emit);
this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit); this.onContentClicked.subscribe(this@ChannelContentsFragment.onContentClicked::emit);
this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit); this.onChannelClicked.subscribe(this@ChannelContentsFragment.onChannelClicked::emit);
this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit); this.onAddToClicked.subscribe(this@ChannelContentsFragment.onAddToClicked::emit);
this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit); this.onAddToQueueClicked.subscribe(this@ChannelContentsFragment.onAddToQueueClicked::emit);
this.onLongPress.subscribe(this@ChannelContentsFragment.onLongPress::emit);
} }
_llmVideo = LinearLayoutManager(view.context); _llmVideo = LinearLayoutManager(view.context);
@@ -1,21 +1,20 @@
package com.futo.platformplayer.fragment.channel.tab package com.futo.platformplayer.fragment.channel.tab
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile import com.futo.platformplayer.fragment.mainactivity.main.PolycentricProfile
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.views.SupportView
import com.futo.platformplayer.views.buttons.BigButton
class ChannelMonetizationFragment : Fragment, IChannelTabFragment { class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
private var _buttonStore: BigButton? = null; private var _supportView: SupportView? = null
private var _textMonetization: TextView? = null
private var _lastChannel: IPlatformChannel? = null; private var _lastChannel: IPlatformChannel? = null;
private var _lastPolycentricProfile: PolycentricProfile? = null; private var _lastPolycentricProfile: PolycentricProfile? = null;
@@ -24,51 +23,39 @@ class ChannelMonetizationFragment : Fragment, IChannelTabFragment {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_channel_monetization, container, false); val view = inflater.inflate(R.layout.fragment_channel_monetization, container, false);
_buttonStore = view.findViewById(R.id.button_store); _supportView = view.findViewById(R.id.support);
_textMonetization = view.findViewById(R.id.text_monetization);
_buttonStore?.onClick?.subscribe {
_lastPolycentricProfile?.systemState?.store?.let {
try {
val uri = Uri.parse(it);
val intent = Intent(Intent.ACTION_VIEW)
intent.data = uri
startActivity(intent)
} catch (e: Throwable) {
Logger.e(TAG, "Failed to open URI: '${it}'.", e);
}
}
};
_lastChannel?.also { _lastChannel?.also {
setChannel(it); setChannel(it);
}; };
_lastPolycentricProfile?.also { _supportView?.visibility = View.GONE;
setPolycentricProfile(it, animate = false); _textMonetization?.visibility = View.GONE;
} setPolycentricProfile(_lastPolycentricProfile, animate = false);
return view; return view;
} }
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView(); super.onDestroyView();
_buttonStore = null; _supportView = null;
_textMonetization = null;
} }
override fun setChannel(channel: IPlatformChannel) { override fun setChannel(channel: IPlatformChannel) {
_lastChannel = channel; _lastChannel = channel;
_buttonStore?.visibility = View.GONE;
} }
fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) { fun setPolycentricProfile(polycentricProfile: PolycentricProfile?, animate: Boolean) {
_lastPolycentricProfile = polycentricProfile; _lastPolycentricProfile = polycentricProfile
if (polycentricProfile != null) {
if (polycentricProfile == null) { _supportView?.setPolycentricProfile(polycentricProfile, animate)
return; _supportView?.visibility = View.VISIBLE
} _textMonetization?.visibility = View.GONE
} else {
if (polycentricProfile.systemState.store.isNotEmpty()) { _supportView?.setPolycentricProfile(null, animate)
_buttonStore?.visibility = View.VISIBLE; _supportView?.visibility = View.GONE
_textMonetization?.visibility = View.VISIBLE
} }
} }
@@ -225,7 +225,7 @@ class MenuBottomBarFragment : MainActivityFragment() {
if (faqIndex != -1) { if (faqIndex != -1) {
val button = buttons[faqIndex] val button = buttons[faqIndex]
buttons.removeAt(faqIndex) buttons.removeAt(faqIndex)
buttons.add(1, button) buttons.add(if (buttons.size == 1) 1 else 0, button)
} }
for (data in buttons) { for (data in buttons) {
@@ -252,8 +252,8 @@ class MenuBottomBarFragment : MainActivityFragment() {
val defs = currentButtonDefinitions?.toMutableList() ?: return val defs = currentButtonDefinitions?.toMutableList() ?: return
val metrics = StateApp.instance.displayMetrics ?: resources.displayMetrics; val metrics = StateApp.instance.displayMetrics ?: resources.displayMetrics;
_buttonsVisible = floor(metrics.widthPixels.toDouble() / 65.dp(resources).toDouble()).roundToInt(); _buttonsVisible = floor(metrics.widthPixels.toDouble() / 65.dp(resources).toDouble()).roundToInt();
if (_buttonsVisible - 2 >= defs.size) { if (_buttonsVisible - 1 >= defs.size) {
updateBottomMenuButtons(defs.slice(IntRange(0, defs.size - 1)).toMutableList(), false); updateBottomMenuButtons(defs.toMutableList(), false);
} else { } else {
updateBottomMenuButtons(defs.slice(IntRange(0, _buttonsVisible - 2)).toMutableList(), true); updateBottomMenuButtons(defs.slice(IntRange(0, _buttonsVisible - 2)).toMutableList(), true);
updateMoreButtons(defs.slice(IntRange(_buttonsVisible - 1, defs.size - 1)).toMutableList()); updateMoreButtons(defs.slice(IntRange(_buttonsVisible - 1, defs.size - 1)).toMutableList());
@@ -91,7 +91,7 @@ class BuyFragment : MainFragment() {
val price = prices[currency.id]!!; val price = prices[currency.id]!!;
val priceDecimal = (price.toDouble() / 100); val priceDecimal = (price.toDouble() / 100);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
_buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal); _buttonBuyText.text = currency.symbol + String.format("%.2f", priceDecimal) + context.getString(R.string.plus_tax);
} }
} }
} }
@@ -170,6 +170,10 @@ class ChannelFragment : MainFragment() {
_buttonSubscribe.onSubscribed.subscribe { _buttonSubscribe.onSubscribed.subscribe {
UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer); UISlideOverlays.showSubscriptionOptionsOverlay(it, _overlayContainer);
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
}
_buttonSubscribe.onUnSubscribed.subscribe {
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
} }
_buttonSubscriptionSettings.setOnClickListener { _buttonSubscriptionSettings.setOnClickListener {
@@ -206,6 +210,9 @@ class ChannelFragment : MainFragment() {
UIDialogs.toast(context, "Queued [$name]", false); UIDialogs.toast(context, "Queued [$name]", false);
} }
} }
adapter.onUrlClicked.subscribe { url ->
fragment.navigate<BrowserFragment>(url);
}
adapter.onContentUrlClicked.subscribe { url, contentType -> adapter.onContentUrlClicked.subscribe { url, contentType ->
when(contentType) { when(contentType) {
ContentType.MEDIA -> { ContentType.MEDIA -> {
@@ -216,6 +223,12 @@ class ChannelFragment : MainFragment() {
else -> {}; else -> {};
} }
} }
adapter.onLongPress.subscribe { content ->
_overlayContainer.let {
if(content is IPlatformVideo)
_slideUpOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it);
}
}
viewPager.adapter = adapter; viewPager.adapter = adapter;
val tabLayoutMediator = TabLayoutMediator(tabs, viewPager) { tab, position -> val tabLayoutMediator = TabLayoutMediator(tabs, viewPager) { tab, position ->
@@ -382,14 +395,18 @@ class ChannelFragment : MainFragment() {
}); });
}); });
val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url); _fragment.lifecycleScope.launch(Dispatchers.IO) {
if (plugin != null && plugin.capabilities.hasSearchChannelContents) { val plugin = StatePlatform.instance.getChannelClientOrNull(channel.url);
buttons.add(Pair(R.drawable.ic_search) { withContext(Dispatchers.Main) {
_fragment.navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO, channel.url)); if (plugin != null && plugin.capabilities.hasSearchChannelContents) {
}); buttons.add(Pair(R.drawable.ic_search) {
} _fragment.navigate<SuggestionsFragment>(SuggestionsFragmentData("", SearchType.VIDEO, channel.url));
});
_fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons); _fragment.topBar?.assume<NavigationTopBarFragment>()?.setMenuItems(buttons);
}
}
}
_buttonSubscribe.setSubscribeChannel(channel); _buttonSubscribe.setSubscribeChannel(channel);
_buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE; _buttonSubscriptionSettings.visibility = if(_buttonSubscribe.isSubscribed) View.VISIBLE else View.GONE;
@@ -1,6 +1,5 @@
package com.futo.platformplayer.fragment.mainactivity.main package com.futo.platformplayer.fragment.mainactivity.main
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
@@ -17,16 +16,16 @@ import com.futo.platformplayer.api.media.structures.*
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateMeta import com.futo.platformplayer.states.StateMeta
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.video.PlayerManager import com.futo.platformplayer.video.PlayerManager
import com.futo.platformplayer.views.FeedStyle import com.futo.platformplayer.views.FeedStyle
import com.futo.platformplayer.views.adapters.PreviewContentListAdapter import com.futo.platformplayer.views.adapters.feedtypes.PreviewContentListAdapter
import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder import com.futo.platformplayer.views.adapters.ContentPreviewViewHolder
import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader import com.futo.platformplayer.views.adapters.InsertedViewAdapterWithLoader
import com.futo.platformplayer.views.adapters.InsertedViewHolder import com.futo.platformplayer.views.adapters.InsertedViewHolder
import com.futo.platformplayer.views.adapters.PreviewNestedVideoViewHolder import com.futo.platformplayer.views.adapters.feedtypes.PreviewNestedVideoViewHolder
import com.futo.platformplayer.views.adapters.PreviewVideoViewHolder import com.futo.platformplayer.views.adapters.feedtypes.PreviewVideoViewHolder
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuItem
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuOverlay
import kotlin.math.floor import kotlin.math.floor
abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent, IPlatformContent, IPager<IPlatformContent>, ContentPreviewViewHolder> where TFragment : MainFragment { abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent, IPlatformContent, IPager<IPlatformContent>, ContentPreviewViewHolder> where TFragment : MainFragment {
@@ -37,6 +36,8 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
private var _previewsEnabled: Boolean = true; private var _previewsEnabled: Boolean = true;
override val visibleThreshold: Int get() = if (feedStyle == FeedStyle.PREVIEW) { 5 } else { 10 }; override val visibleThreshold: Int get() = if (feedStyle == FeedStyle.PREVIEW) { 5 } else { 10 };
protected lateinit var headerView: LinearLayout; protected lateinit var headerView: LinearLayout;
private var _videoOptionsOverlay: SlideUpMenuOverlay? = null;
protected open val shouldShowTimeBar: Boolean get() = true
constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) { constructor(fragment: TFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
@@ -57,39 +58,22 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
}; };
headerView = v; headerView = v;
return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(v)).apply { return PreviewContentListAdapter(context, feedStyle, dataset, player, _previewsEnabled, arrayListOf(v), arrayListOf(), shouldShowTimeBar).apply {
attachAdapterEvents(this); attachAdapterEvents(this);
} }
} }
private fun attachAdapterEvents(adapter: PreviewContentListAdapter) { private fun attachAdapterEvents(adapter: PreviewContentListAdapter) {
adapter.onContentUrlClicked.subscribe(this, this@ContentFeedView::onContentUrlClicked); adapter.onContentUrlClicked.subscribe(this, this@ContentFeedView::onContentUrlClicked);
adapter.onUrlClicked.subscribe(this, this@ContentFeedView::onUrlClicked);
adapter.onContentClicked.subscribe(this) { content, time -> adapter.onContentClicked.subscribe(this) { content, time ->
this@ContentFeedView.onContentClicked(content, time); this@ContentFeedView.onContentClicked(content, time);
}; };
adapter.onChannelClicked.subscribe(this) { fragment.navigate<ChannelFragment>(it) }; adapter.onChannelClicked.subscribe(this) { fragment.navigate<ChannelFragment>(it) };
adapter.onAddToClicked.subscribe(this) { content -> adapter.onAddToClicked.subscribe(this) { content ->
//TODO: Reconstruct search video from detail if search is null //TODO: Reconstruct search video from detail if search is null
_overlayContainer.let { if(content is IPlatformVideo) {
if(content is IPlatformVideo) showVideoOptionsOverlay(content)
UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(context, R.drawable.ic_visibility_off, context.getString(R.string.hide), context.getString(R.string.hide_from_home), "hide",
{ StateMeta.instance.addHiddenVideo(content.url);
if (fragment is HomeFragment) {
val removeIndex = recyclerData.results.indexOf(content);
if (removeIndex >= 0) {
recyclerData.results.removeAt(removeIndex);
recyclerData.adapter.notifyItemRemoved(recyclerData.adapter.childToParentPosition(removeIndex));
}
}
}),
SlideUpMenuItem(context, R.drawable.ic_playlist, context.getString(R.string.play_feed_as_queue), context.getString(R.string.play_entire_feed), "playFeed",
{
val newQueue = listOf(content) + recyclerData.results
.filterIsInstance<IPlatformVideo>()
.filter { it != content };
StatePlayer.instance.setQueue(newQueue, StatePlayer.TYPE_QUEUE, "Feed Queue", true, false);
})
);
} }
}; };
adapter.onAddToQueueClicked.subscribe(this) { adapter.onAddToQueueClicked.subscribe(this) {
@@ -99,15 +83,61 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
UIDialogs.toast(context, context.getString(R.string.queued) + " [$name]", false); UIDialogs.toast(context, context.getString(R.string.queued) + " [$name]", false);
} }
}; };
adapter.onLongPress.subscribe(this) {
if (it is IPlatformVideo) {
showVideoOptionsOverlay(it)
}
};
}
fun onBackPressed(): Boolean {
val videoOptionsOverlay = _videoOptionsOverlay
if (videoOptionsOverlay != null) {
if (videoOptionsOverlay.isVisible) {
videoOptionsOverlay.hide();
_videoOptionsOverlay = null
return true;
}
_videoOptionsOverlay = null
return false
}
return false
}
private fun showVideoOptionsOverlay(content: IPlatformVideo) {
_overlayContainer.let {
_videoOptionsOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(context, R.drawable.ic_visibility_off, context.getString(R.string.hide), context.getString(R.string.hide_from_home), "hide",
{ StateMeta.instance.addHiddenVideo(content.url);
if (fragment is HomeFragment) {
val removeIndex = recyclerData.results.indexOf(content);
if (removeIndex >= 0) {
recyclerData.results.removeAt(removeIndex);
recyclerData.adapter.notifyItemRemoved(recyclerData.adapter.childToParentPosition(removeIndex));
}
}
}),
SlideUpMenuItem(context, R.drawable.ic_playlist, context.getString(R.string.play_feed_as_queue), context.getString(R.string.play_entire_feed), "playFeed",
{
val newQueue = listOf(content) + recyclerData.results
.filterIsInstance<IPlatformVideo>()
.filter { it != content };
StatePlayer.instance.setQueue(newQueue, StatePlayer.TYPE_QUEUE, "Feed Queue", true, false);
})
);
}
} }
private fun detachAdapterEvents() { private fun detachAdapterEvents() {
val adapter = recyclerData.adapter as PreviewContentListAdapter? ?: return; val adapter = recyclerData.adapter as PreviewContentListAdapter? ?: return;
adapter.onContentUrlClicked.remove(this); adapter.onContentUrlClicked.remove(this);
adapter.onUrlClicked.remove(this);
adapter.onContentClicked.remove(this); adapter.onContentClicked.remove(this);
adapter.onChannelClicked.remove(this); adapter.onChannelClicked.remove(this);
adapter.onAddToClicked.remove(this); adapter.onAddToClicked.remove(this);
adapter.onAddToQueueClicked.remove(this); adapter.onAddToQueueClicked.remove(this);
adapter.onLongPress.remove(this);
} }
override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) { override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) {
@@ -137,11 +167,14 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
protected open fun onContentClicked(content: IPlatformContent, time: Long) { protected open fun onContentClicked(content: IPlatformContent, time: Long) {
if(content is IPlatformVideo) { if(content is IPlatformVideo) {
StatePlayer.instance.clearQueue(); if (StatePlayer.instance.hasQueue) {
if (Settings.instance.playback.shouldResumePreview(time)) StatePlayer.instance.addToQueue(content)
fragment.navigate<VideoDetailFragment>(content.withTimestamp(time)).maximizeVideoDetail(); } else {
else if (Settings.instance.playback.shouldResumePreview(time))
fragment.navigate<VideoDetailFragment>(content).maximizeVideoDetail(); fragment.navigate<VideoDetailFragment>(content.withTimestamp(time)).maximizeVideoDetail();
else
fragment.navigate<VideoDetailFragment>(content).maximizeVideoDetail();
}
} else if (content is IPlatformPlaylist) { } else if (content is IPlatformPlaylist) {
fragment.navigate<PlaylistFragment>(content); fragment.navigate<PlaylistFragment>(content);
} else if (content is IPlatformPost) { } else if (content is IPlatformPost) {
@@ -159,6 +192,9 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
else -> {}; else -> {};
} }
} }
protected open fun onUrlClicked(url: String) {
fragment.navigate<BrowserFragment>(url);
}
private fun playPreview() { private fun playPreview() {
if(feedStyle == FeedStyle.THUMBNAIL) if(feedStyle == FeedStyle.THUMBNAIL)
@@ -62,8 +62,15 @@ class ContentSearchResultsFragment : MainFragment() {
_view = null; _view = null;
} }
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true)
return true
return super.onBackPressed()
}
fun setPreviewsEnabled(previewsEnabled: Boolean) { fun setPreviewsEnabled(previewsEnabled: Boolean) {
_view?.setPreviewsEnabled(previewsEnabled); _view?.setPreviewsEnabled(previewsEnabled && Settings.instance.search.previewFeedItems);
} }
@SuppressLint("ViewConstructor") @SuppressLint("ViewConstructor")
@@ -77,6 +84,7 @@ class ContentSearchResultsFragment : MainFragment() {
private var _channelUrl: String? = null; private var _channelUrl: String? = null;
private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>; private val _taskSearch: TaskHandler<String, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.search.progressBar
constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) { constructor(fragment: ContentSearchResultsFragment, inflater: LayoutInflater) : super(fragment, inflater) {
_taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query -> _taskSearch = TaskHandler<String, IPager<IPlatformContent>>({fragment.lifecycleScope}, { query ->
@@ -93,6 +101,8 @@ class ContentSearchResultsFragment : MainFragment() {
Logger.w(TAG, "Failed to load results.", it); Logger.w(TAG, "Failed to load results.", it);
UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() }); UIDialogs.showGeneralRetryErrorDialog(context, it.message ?: "", it, { loadResults() });
} }
setPreviewsEnabled(Settings.instance.search.previewFeedItems);
} }
override fun cleanup() { override fun cleanup() {
@@ -6,10 +6,12 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.FrameLayout
import android.widget.Spinner import android.widget.Spinner
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.views.adapters.SubscriptionAdapter import com.futo.platformplayer.views.adapters.SubscriptionAdapter
class CreatorsFragment : MainFragment() { class CreatorsFragment : MainFragment() {
@@ -18,13 +20,16 @@ class CreatorsFragment : MainFragment() {
override val hasBottomBar: Boolean get() = true; override val hasBottomBar: Boolean get() = true;
private var _spinnerSortBy: Spinner? = null; private var _spinnerSortBy: Spinner? = null;
private var _overlayContainer: FrameLayout? = null;
override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateMainView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.fragment_creators, container, false); val view = inflater.inflate(R.layout.fragment_creators, container, false);
val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription)); val adapter = SubscriptionAdapter(inflater, getString(R.string.confirm_delete_subscription));
adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) }; adapter.onClick.subscribe { platformUser -> navigate<ChannelFragment>(platformUser) };
adapter.onSettings.subscribe { sub -> _overlayContainer?.let { UISlideOverlays.showSubscriptionOptionsOverlay(sub, it) } }
_overlayContainer = view.findViewById(R.id.overlay_container);
val spinnerSortBy: Spinner = view.findViewById(R.id.spinner_sortby); val spinnerSortBy: Spinner = view.findViewById(R.id.spinner_sortby);
spinnerSortBy.adapter = ArrayAdapter(view.context, R.layout.spinner_item_simple, resources.getStringArray(R.array.subscriptions_sortby_array)).also { spinnerSortBy.adapter = ArrayAdapter(view.context, R.layout.spinner_item_simple, resources.getStringArray(R.array.subscriptions_sortby_array)).also {
it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple); it.setDropDownViewResource(R.layout.spinner_dropdownitem_simple);
@@ -48,6 +53,7 @@ class CreatorsFragment : MainFragment() {
override fun onDestroyMainView() { override fun onDestroyMainView() {
super.onDestroyMainView(); super.onDestroyMainView();
_spinnerSortBy = null; _spinnerSortBy = null;
_overlayContainer = null;
} }
companion object { companion object {
@@ -66,6 +66,13 @@ class HomeFragment : MainFragment() {
return view; return view;
} }
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true)
return true
return super.onBackPressed()
}
override fun onDestroyMainView() { override fun onDestroyMainView() {
super.onDestroyMainView(); super.onDestroyMainView();
@@ -78,7 +85,7 @@ class HomeFragment : MainFragment() {
} }
fun setPreviewsEnabled(previewsEnabled: Boolean) { fun setPreviewsEnabled(previewsEnabled: Boolean) {
_view?.setPreviewsEnabled(previewsEnabled); _view?.setPreviewsEnabled(previewsEnabled && Settings.instance.home.previewFeedItems);
} }
@SuppressLint("ViewConstructor") @SuppressLint("ViewConstructor")
@@ -88,6 +95,7 @@ class HomeFragment : MainFragment() {
private var _announcementsView: AnnouncementView; private var _announcementsView: AnnouncementView;
private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>; private val _taskGetPager: TaskHandler<Boolean, IPager<IPlatformContent>>;
override val shouldShowTimeBar: Boolean get() = Settings.instance.home.progressBar
constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) { constructor(fragment: HomeFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
_announcementsView = AnnouncementView(context, null).apply { _announcementsView = AnnouncementView(context, null).apply {
@@ -122,6 +130,8 @@ class HomeFragment : MainFragment() {
setLoading(false); setLoading(false);
}; };
}; };
setPreviewsEnabled(Settings.instance.home.previewFeedItems);
} }
fun onShown() { fun onShown() {
@@ -150,7 +160,7 @@ class HomeFragment : MainFragment() {
} }
override fun filterResults(contents: List<IPlatformContent>): List<IPlatformContent> { override fun filterResults(contents: List<IPlatformContent>): List<IPlatformContent> {
return contents.filter { it !is IPlatformVideo || !StateMeta.instance.isVideoHidden(it.url) }; return contents.filter { !StateMeta.instance.isVideoHidden(it.url) && !StateMeta.instance.isCreatorHidden(it.author.url) };
} }
private fun loadResults() { private fun loadResults() {
@@ -63,6 +63,7 @@ class ImportSubscriptionsFragment : MainFragment() {
private var _textSelectDeselectAll: TextView; private var _textSelectDeselectAll: TextView;
private var _textNothingToImport: TextView; private var _textNothingToImport: TextView;
private var _textCounter: TextView; private var _textCounter: TextView;
private var _textLoadMore: TextView;
private var _adapterView: AnyAdapterView<SelectableIPlatformChannel, ImportSubscriptionViewHolder>; private var _adapterView: AnyAdapterView<SelectableIPlatformChannel, ImportSubscriptionViewHolder>;
private var _links: List<String> = listOf(); private var _links: List<String> = listOf();
private val _items: ArrayList<SelectableIPlatformChannel> = arrayListOf(); private val _items: ArrayList<SelectableIPlatformChannel> = arrayListOf();
@@ -79,6 +80,7 @@ class ImportSubscriptionsFragment : MainFragment() {
_textNothingToImport = findViewById(R.id.nothing_to_import); _textNothingToImport = findViewById(R.id.nothing_to_import);
_textSelectDeselectAll = findViewById(R.id.text_select_deselect_all); _textSelectDeselectAll = findViewById(R.id.text_select_deselect_all);
_textCounter = findViewById(R.id.text_select_counter); _textCounter = findViewById(R.id.text_select_counter);
_textLoadMore = findViewById(R.id.text_load_more);
_spinner = findViewById(R.id.channel_loader); _spinner = findViewById(R.id.channel_loader);
_adapterView = findViewById<RecyclerView>(R.id.recycler_import).asAny( _items) { _adapterView = findViewById<RecyclerView>(R.id.recycler_import).asAny( _items) {
@@ -120,6 +122,19 @@ class ImportSubscriptionsFragment : MainFragment() {
//UIDialogs.showDataRetryDialog(layoutInflater, { load(); }); //UIDialogs.showDataRetryDialog(layoutInflater, { load(); });
loadNext(); loadNext();
}; };
_textLoadMore.setOnClickListener {
if (!_limitToastShown) {
return@setOnClickListener;
}
_textLoadMore.visibility = View.GONE;
_limitToastShown = false;
_counter = 0;
load();
};
_textLoadMore.visibility = View.GONE;
} }
fun cleanup() { fun cleanup() {
@@ -165,7 +180,8 @@ class ImportSubscriptionsFragment : MainFragment() {
if (_counter >= MAXIMUM_BATCH_SIZE) { if (_counter >= MAXIMUM_BATCH_SIZE) {
if (!_limitToastShown) { if (!_limitToastShown) {
_limitToastShown = true; _limitToastShown = true;
UIDialogs.toast(context, "Stopped after {requestCount} to avoid rate limit, re-enter to import rest".replace("{requestCount}", MAXIMUM_BATCH_SIZE.toString())); _textLoadMore.visibility = View.VISIBLE;
UIDialogs.toast(context, context.getString(R.string.stopped_after_requestcount_to_avoid_rate_limit_click_load_more_to_load_more).replace("{requestCount}", MAXIMUM_BATCH_SIZE.toString()));
} }
setLoading(false); setLoading(false);
@@ -210,7 +226,7 @@ class ImportSubscriptionsFragment : MainFragment() {
companion object { companion object {
val TAG = "ImportSubscriptionsFragment"; val TAG = "ImportSubscriptionsFragment";
private const val MAXIMUM_BATCH_SIZE = 75; private const val MAXIMUM_BATCH_SIZE = 100;
fun newInstance() = ImportSubscriptionsFragment().apply {} fun newInstance() = ImportSubscriptionsFragment().apply {}
} }
} }
@@ -44,6 +44,13 @@ class PlaylistSearchResultsFragment : MainFragment() {
return view; return view;
} }
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true)
return true
return super.onBackPressed()
}
override fun onDestroyMainView() { override fun onDestroyMainView() {
super.onDestroyMainView(); super.onDestroyMainView();
_view?.cleanup(); _view?.cleanup();
@@ -17,6 +17,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.futo.platformplayer.states.StatePlayer import com.futo.platformplayer.states.StatePlayer
import com.futo.platformplayer.states.StatePlaylists import com.futo.platformplayer.states.StatePlaylists
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.assume import com.futo.platformplayer.assume
import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment import com.futo.platformplayer.fragment.mainactivity.topbar.NavigationTopBarFragment
@@ -54,6 +55,14 @@ class PlaylistsFragment : MainFragment() {
_view?.onShown(parameter, isBack); _view?.onShown(parameter, isBack);
} }
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true) {
return true;
}
return super.onBackPressed()
}
@SuppressLint("ViewConstructor") @SuppressLint("ViewConstructor")
class PlaylistsView : LinearLayout { class PlaylistsView : LinearLayout {
private val _fragment: PlaylistsFragment; private val _fragment: PlaylistsFragment;
@@ -64,6 +73,7 @@ class PlaylistsFragment : MainFragment() {
private var _adapterWatchLater: VideoListHorizontalAdapter; private var _adapterWatchLater: VideoListHorizontalAdapter;
private var _adapterPlaylist: PlaylistsAdapter; private var _adapterPlaylist: PlaylistsAdapter;
private var _layoutWatchlist: ConstraintLayout; private var _layoutWatchlist: ConstraintLayout;
private var _slideUpOverlay: SlideUpMenuOverlay? = null;
constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) { constructor(fragment: PlaylistsFragment, inflater: LayoutInflater) : super(inflater.context) {
_fragment = fragment; _fragment = fragment;
@@ -92,41 +102,24 @@ class PlaylistsFragment : MainFragment() {
recyclerPlaylists.adapter = _adapterPlaylist; recyclerPlaylists.adapter = _adapterPlaylist;
recyclerPlaylists.layoutManager = LinearLayoutManager(context); recyclerPlaylists.layoutManager = LinearLayoutManager(context);
val nameInput = SlideUpMenuTextInput(context, context.getString(R.string.name));
val addPlaylistOverlay = SlideUpMenuOverlay(context, findViewById<FrameLayout>(R.id.overlay_create_playlist), context.getString(R.string.create_new_playlist), context.getString(R.string.ok), false, nameInput);
val buttonCreatePlaylist = findViewById<ImageButton>(R.id.button_create_playlist);
buttonCreatePlaylist.setOnClickListener {
_slideUpOverlay = UISlideOverlays.showCreatePlaylistOverlay(findViewById<FrameLayout>(R.id.overlay_create_playlist)) {
val playlist = Playlist(it, arrayListOf());
playlists.add(0, playlist);
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
_adapterPlaylist.notifyItemInserted(0);
};
};
_adapterPlaylist.onClick.subscribe { p -> _fragment.navigate<PlaylistFragment>(p); }; _adapterPlaylist.onClick.subscribe { p -> _fragment.navigate<PlaylistFragment>(p); };
_adapterPlaylist.onPlay.subscribe { p -> _adapterPlaylist.onPlay.subscribe { p ->
StatePlayer.instance.setPlaylist(p, 0, true); StatePlayer.instance.setPlaylist(p, 0, true);
}; };
addPlaylistOverlay.onOK.subscribe {
val text = nameInput.text;
if (text.isBlank()) {
return@subscribe;
}
val playlist = Playlist(text, arrayListOf());
playlists.add(0, playlist);
StatePlaylists.instance.createOrUpdatePlaylist(playlist);
_adapterPlaylist.notifyItemInserted(0);
addPlaylistOverlay.hide();
nameInput.deactivate();
nameInput.clear();
};
addPlaylistOverlay.onCancel.subscribe {
nameInput.deactivate();
nameInput.clear();
};
val buttonCreatePlaylist = findViewById<ImageButton>(R.id.button_create_playlist);
buttonCreatePlaylist.setOnClickListener {
addPlaylistOverlay.show();
nameInput.activate();
};
_appBar = findViewById(R.id.app_bar); _appBar = findViewById(R.id.app_bar);
_layoutWatchlist = findViewById(R.id.layout_watchlist); _layoutWatchlist = findViewById(R.id.layout_watchlist);
@@ -142,12 +135,28 @@ class PlaylistsFragment : MainFragment() {
fun onShown(parameter: Any?, isBack: Boolean) { fun onShown(parameter: Any?, isBack: Boolean) {
playlists.clear() playlists.clear()
playlists.addAll(StatePlaylists.instance.getPlaylists().sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) }); playlists.addAll(
StatePlaylists.instance.getPlaylists()
.sortedByDescending { maxOf(it.datePlayed, it.dateUpdate, it.dateCreation) });
_adapterPlaylist.notifyDataSetChanged(); _adapterPlaylist.notifyDataSetChanged();
updateWatchLater(); updateWatchLater();
} }
fun onBackPressed(): Boolean {
val slideUpOverlay = _slideUpOverlay;
if (slideUpOverlay != null) {
if (slideUpOverlay.isVisible) {
slideUpOverlay.hide();
return true;
}
return false;
}
return false;
}
private fun updateWatchLater() { private fun updateWatchLater() {
val watchList = StatePlaylists.instance.getWatchLater(); val watchList = StatePlaylists.instance.getWatchLater();
if (watchList.isNotEmpty()) { if (watchList.isNotEmpty()) {
@@ -20,7 +20,6 @@ import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.UISlideOverlays
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.Thumbnails import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
@@ -32,6 +31,7 @@ import com.futo.platformplayer.api.media.models.ratings.RatingLikes
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dp import com.futo.platformplayer.dp
import com.futo.platformplayer.fixHtmlWhitespace import com.futo.platformplayer.fixHtmlWhitespace
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.polycentric.PolycentricCache import com.futo.platformplayer.polycentric.PolycentricCache
@@ -46,7 +46,7 @@ import com.futo.platformplayer.views.others.CreatorThumbnail
import com.futo.platformplayer.views.platform.PlatformIndicator import com.futo.platformplayer.views.platform.PlatformIndicator
import com.futo.platformplayer.views.subscriptions.SubscribeButton import com.futo.platformplayer.views.subscriptions.SubscribeButton
import com.futo.platformplayer.views.others.Toggle import com.futo.platformplayer.views.others.Toggle
import com.futo.platformplayer.views.adapters.PreviewPostView import com.futo.platformplayer.views.adapters.feedtypes.PreviewPostView
import com.futo.platformplayer.views.overlays.RepliesOverlay import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.polycentric.core.ApiMethods import com.futo.polycentric.core.ApiMethods
@@ -364,7 +364,7 @@ class PostDetailFragment : MainFragment {
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
Logger.i(TAG, "Started backfill"); Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServers(); args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill"); Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e) Logger.e(TAG, "Failed to backfill servers", e)
@@ -80,12 +80,21 @@ class SubscriptionsFeedFragment : MainFragment() {
} }
} }
override fun onBackPressed(): Boolean {
if (_view?.onBackPressed() == true)
return true
return super.onBackPressed()
}
fun setPreviewsEnabled(previewsEnabled: Boolean) { fun setPreviewsEnabled(previewsEnabled: Boolean) {
_view?.setPreviewsEnabled(previewsEnabled); _view?.setPreviewsEnabled(previewsEnabled && Settings.instance.subscriptions.previewFeedItems);
} }
@SuppressLint("ViewConstructor") @SuppressLint("ViewConstructor")
class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> { class SubscriptionsFeedView : ContentFeedView<SubscriptionsFeedFragment> {
override val shouldShowTimeBar: Boolean get() = Settings.instance.subscriptions.progressBar
constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) { constructor(fragment: SubscriptionsFeedFragment, inflater: LayoutInflater, cachedRecyclerData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>? = null) : super(fragment, inflater, cachedRecyclerData) {
Logger.i(TAG, "SubscriptionsFeedFragment constructor()"); Logger.i(TAG, "SubscriptionsFeedFragment constructor()");
StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total -> StateSubscriptions.instance.onGlobalSubscriptionsUpdateProgress.subscribe(this) { progress, total ->
@@ -108,6 +117,8 @@ class SubscriptionsFeedFragment : MainFragment() {
}; };
initializeToolbarContent(); initializeToolbarContent();
setPreviewsEnabled(Settings.instance.subscriptions.previewFeedItems);
} }
fun onShown() { fun onShown() {
@@ -119,7 +130,7 @@ class SubscriptionsFeedFragment : MainFragment() {
recyclerData.lastLoad.getNowDiffSeconds() > 60 ) { recyclerData.lastLoad.getNowDiffSeconds() > 60 ) {
recyclerData.lastLoad = OffsetDateTime.now(); recyclerData.lastLoad = OffsetDateTime.now();
if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5) if(StateSubscriptions.instance.getOldestUpdateTime().getNowDiffMinutes() > 5 && Settings.instance.subscriptions.fetchOnTabOpen)
loadResults(false); loadResults(false);
else if(recyclerData.results.size == 0) else if(recyclerData.results.size == 0)
loadCache(); loadCache();
@@ -176,9 +187,9 @@ class SubscriptionsFeedFragment : MainFragment() {
private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({StateApp.instance.scope}, { withRefresh -> private val _taskGetPager = TaskHandler<Boolean, IPager<IPlatformContent>>({StateApp.instance.scope}, { withRefresh ->
if(!_bypassRateLimit) { if(!_bypassRateLimit) {
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount(); val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.config.subscriptionRateLimit}" }.joinToString("\n"); val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
val rateLimitPlugins = subRequestCounts.filter { clientCount -> clientCount.key.config.subscriptionRateLimit?.let { rateLimit -> clientCount.value > rateLimit } == true } val rateLimitPlugins = subRequestCounts.filter { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true }
Logger.w(TAG, "Refreshing subscriptions with requests:\n" + reqCountStr); Logger.w(TAG, "Trying to refreshing subscriptions with requests:\n" + reqCountStr);
if(rateLimitPlugins.any()) if(rateLimitPlugins.any())
throw RateLimitException(rateLimitPlugins.map { it.key.id }); throw RateLimitException(rateLimitPlugins.map { it.key.id });
} }
@@ -191,7 +202,15 @@ class SubscriptionsFeedFragment : MainFragment() {
return@TaskHandler resp; return@TaskHandler resp;
}) })
.success { loadedResult(it); } .success {
if(!Settings.instance.subscriptions.alwaysReloadFromCache)
loadedResult(it);
else {
finishRefreshLayoutLoader();
setLoading(false);
loadCache();
}
} //TODO: Remove
.exception<RateLimitException> { .exception<RateLimitException> {
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
val subs = StateSubscriptions.instance.getSubscriptions(); val subs = StateSubscriptions.instance.getSubscriptions();
@@ -254,13 +273,16 @@ class SubscriptionsFeedFragment : MainFragment() {
else null; else null;
_filterSettings.save(); _filterSettings.save();
}; };
loadResults(false) if(Settings.instance.subscriptions.fetchOnTabOpen) //TODO: Do this different, temporary workaround
loadResults(false);
else
loadCache();
} }
override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> { override fun filterResults(results: List<IPlatformContent>): List<IPlatformContent> {
val nowSoon = OffsetDateTime.now().plusMinutes(5); val nowSoon = OffsetDateTime.now().plusMinutes(5);
return results.filter { return results.filter {
val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO) ContentType.MEDIA else it.contentType); val allowedContentType = _filterSettings.allowContentTypes.contains(if(it.contentType == ContentType.NESTED_VIDEO || it.contentType == ContentType.LOCKED) ContentType.MEDIA else it.contentType);
if(it.datetime?.isAfter(nowSoon) == true) { if(it.datetime?.isAfter(nowSoon) == true) {
if(!_filterSettings.allowPlanned) if(!_filterSettings.allowPlanned)
@@ -282,6 +304,7 @@ class SubscriptionsFeedFragment : MainFragment() {
loadResults(true); loadResults(true);
} }
private fun loadCache() { private fun loadCache() {
Logger.i(TAG, "Subscriptions load cache"); Logger.i(TAG, "Subscriptions load cache");
val cachePager = ChannelContentCache.instance.getSubscriptionCachePager(); val cachePager = ChannelContentCache.instance.getSubscriptionCachePager();
@@ -301,6 +324,10 @@ class SubscriptionsFeedFragment : MainFragment() {
_taskGetPager.run(withRefetch); _taskGetPager.run(withRefetch);
} }
override fun onRestoreCachedData(cachedData: RecyclerData<InsertedViewAdapterWithLoader<ContentPreviewViewHolder>, LinearLayoutManager, IPager<IPlatformContent>, IPlatformContent, IPlatformContent, InsertedViewHolder<ContentPreviewViewHolder>>) {
super.onRestoreCachedData(cachedData);
setTextCentered(if (cachedData.results.isEmpty()) context.getString(R.string.no_results_found_swipe_down_to_refresh) else null);
}
private fun loadedResult(pager: IPager<IPlatformContent>) { private fun loadedResult(pager: IPager<IPlatformContent>) {
Logger.i(TAG, "Subscriptions new pager loaded (${pager.getResults().size})"); Logger.i(TAG, "Subscriptions new pager loaded (${pager.getResults().size})");
@@ -68,8 +68,6 @@ class VideoDetailFragment : MainFragment {
super.onShownWithView(parameter, isBack); super.onShownWithView(parameter, isBack);
Logger.i(TAG, "onShownWithView parameter=$parameter") Logger.i(TAG, "onShownWithView parameter=$parameter")
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
if(parameter is IPlatformVideoDetails) if(parameter is IPlatformVideoDetails)
_viewDetail?.setVideoDetails(parameter, true); _viewDetail?.setVideoDetails(parameter, true);
else if (parameter is IPlatformVideo) else if (parameter is IPlatformVideo)
@@ -105,6 +103,8 @@ class VideoDetailFragment : MainFragment {
return; return;
} }
if(Settings.instance.other.bypassRotationPrevention && orientation == OrientationManager.Orientation.PORTRAIT)
changeOrientation(OrientationManager.Orientation.PORTRAIT);
if(lastOrientation == newOrientation) if(lastOrientation == newOrientation)
return; return;
@@ -174,7 +174,6 @@ class VideoDetailFragment : MainFragment {
_viewDetail?.onStop(); _viewDetail?.onStop();
close(); close();
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
StatePlayer.instance.clearQueue(); StatePlayer.instance.clearQueue();
StatePlayer.instance.setPlayerClosed(); StatePlayer.instance.setPlayerClosed();
} }
@@ -22,6 +22,7 @@ import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.view.WindowManager
import android.widget.* import android.widget.*
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -36,6 +37,8 @@ import com.futo.platformplayer.api.media.LiveChatManager
import com.futo.platformplayer.api.media.PlatformID import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException import com.futo.platformplayer.api.media.exceptions.ContentNotAvailableYetException
import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException import com.futo.platformplayer.api.media.exceptions.NoPlatformClientException
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.PlatformAuthorMembershipLink
import com.futo.platformplayer.api.media.models.chapters.ChapterType import com.futo.platformplayer.api.media.models.chapters.ChapterType
import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment import com.futo.platformplayer.api.media.models.comments.PolycentricPlatformComment
import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor import com.futo.platformplayer.api.media.models.live.ILiveChatWindowDescriptor
@@ -49,6 +52,7 @@ import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
import com.futo.platformplayer.api.media.models.video.IPlatformVideo import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails
import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
import com.futo.platformplayer.api.media.structures.IPager import com.futo.platformplayer.api.media.structures.IPager
import com.futo.platformplayer.casting.CastConnectionState import com.futo.platformplayer.casting.CastConnectionState
@@ -56,6 +60,7 @@ import com.futo.platformplayer.casting.StateCasting
import com.futo.platformplayer.constructs.Event0 import com.futo.platformplayer.constructs.Event0
import com.futo.platformplayer.constructs.Event1 import com.futo.platformplayer.constructs.Event1
import com.futo.platformplayer.constructs.TaskHandler import com.futo.platformplayer.constructs.TaskHandler
import com.futo.platformplayer.dialogs.AutoUpdateDialog
import com.futo.platformplayer.downloads.VideoLocal import com.futo.platformplayer.downloads.VideoLocal
import com.futo.platformplayer.engine.exceptions.ScriptAgeException import com.futo.platformplayer.engine.exceptions.ScriptAgeException
import com.futo.platformplayer.engine.exceptions.ScriptException import com.futo.platformplayer.engine.exceptions.ScriptException
@@ -70,6 +75,7 @@ import com.futo.platformplayer.receivers.MediaControlReceiver
import com.futo.platformplayer.states.* import com.futo.platformplayer.states.*
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
import com.futo.platformplayer.stores.StringArrayStorage import com.futo.platformplayer.stores.StringArrayStorage
import com.futo.platformplayer.views.MonetizationView
import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout import com.futo.platformplayer.views.behavior.TouchInterceptFrameLayout
import com.futo.platformplayer.views.casting.CastView import com.futo.platformplayer.views.casting.CastView
import com.futo.platformplayer.views.comments.AddCommentView import com.futo.platformplayer.views.comments.AddCommentView
@@ -79,6 +85,7 @@ import com.futo.platformplayer.views.overlays.DescriptionOverlay
import com.futo.platformplayer.views.overlays.LiveChatOverlay import com.futo.platformplayer.views.overlays.LiveChatOverlay
import com.futo.platformplayer.views.overlays.QueueEditorOverlay import com.futo.platformplayer.views.overlays.QueueEditorOverlay
import com.futo.platformplayer.views.overlays.RepliesOverlay import com.futo.platformplayer.views.overlays.RepliesOverlay
import com.futo.platformplayer.views.overlays.SupportOverlay
import com.futo.platformplayer.views.overlays.slideup.* import com.futo.platformplayer.views.overlays.slideup.*
import com.futo.platformplayer.views.pills.PillRatingLikesDislikes import com.futo.platformplayer.views.pills.PillRatingLikesDislikes
import com.futo.platformplayer.views.pills.RoundButton import com.futo.platformplayer.views.pills.RoundButton
@@ -117,6 +124,7 @@ class VideoDetailView : ConstraintLayout {
private var _searchVideo: IPlatformVideo? = null; private var _searchVideo: IPlatformVideo? = null;
var video: IPlatformVideoDetails? = null var video: IPlatformVideoDetails? = null
private set; private set;
var videoLocal: VideoLocal? = null;
private var _playbackTracker: IPlaybackTracker? = null; private var _playbackTracker: IPlaybackTracker? = null;
val currentUrl get() = video?.url ?: _searchVideo?.url ?: _url; val currentUrl get() = video?.url ?: _searchVideo?.url ?: _url;
@@ -191,6 +199,7 @@ class VideoDetailView : ConstraintLayout {
private val _container_content_replies: RepliesOverlay; private val _container_content_replies: RepliesOverlay;
private val _container_content_description: DescriptionOverlay; private val _container_content_description: DescriptionOverlay;
private val _container_content_liveChat: LiveChatOverlay; private val _container_content_liveChat: LiveChatOverlay;
private val _container_content_support: SupportOverlay;
private var _container_content_current: View; private var _container_content_current: View;
@@ -200,9 +209,7 @@ class VideoDetailView : ConstraintLayout {
private val _imageDislikeIcon: ImageView; private val _imageDislikeIcon: ImageView;
private val _imageLikeIcon: ImageView; private val _imageLikeIcon: ImageView;
private val _buttonSupport: LinearLayout; private val _monetization: MonetizationView;
private val _buttonStore: LinearLayout;
private val _layoutMonetization: LinearLayout;
private val _buttonMore: RoundButton; private val _buttonMore: RoundButton;
@@ -212,6 +219,9 @@ class VideoDetailView : ConstraintLayout {
private var _lastAudioSource: IAudioSource? = null; private var _lastAudioSource: IAudioSource? = null;
private var _lastSubtitleSource: ISubtitleSource? = null; private var _lastSubtitleSource: ISubtitleSource? = null;
private var _isCasting: Boolean = false; private var _isCasting: Boolean = false;
var isPlaying: Boolean = false
private set;
var lastPositionMilliseconds: Long = 0 var lastPositionMilliseconds: Long = 0
private set; private set;
private var _historicalPosition: Long = 0; private var _historicalPosition: Long = 0;
@@ -292,6 +302,7 @@ class VideoDetailView : ConstraintLayout {
_container_content_replies = findViewById(R.id.videodetail_container_replies); _container_content_replies = findViewById(R.id.videodetail_container_replies);
_container_content_description = findViewById(R.id.videodetail_container_description); _container_content_description = findViewById(R.id.videodetail_container_description);
_container_content_liveChat = findViewById(R.id.videodetail_container_livechat); _container_content_liveChat = findViewById(R.id.videodetail_container_livechat);
_container_content_support = findViewById(R.id.videodetail_container_support)
_textComments = findViewById(R.id.text_comments); _textComments = findViewById(R.id.text_comments);
_addCommentView = findViewById(R.id.add_comment_view); _addCommentView = findViewById(R.id.add_comment_view);
@@ -310,11 +321,7 @@ class VideoDetailView : ConstraintLayout {
_imageLikeIcon = findViewById(R.id.image_like_icon); _imageLikeIcon = findViewById(R.id.image_like_icon);
_imageDislikeIcon = findViewById(R.id.image_dislike_icon); _imageDislikeIcon = findViewById(R.id.image_dislike_icon);
_buttonSupport = findViewById(R.id.button_support); _monetization = findViewById(R.id.monetization);
_buttonStore = findViewById(R.id.button_store);
_layoutMonetization = findViewById(R.id.layout_monetization);
_layoutMonetization.visibility = View.GONE;
_player.attachPlayer(); _player.attachPlayer();
@@ -327,16 +334,12 @@ class VideoDetailView : ConstraintLayout {
fragment.navigate<VideoDetailFragment>(it.targetUrl); fragment.navigate<VideoDetailFragment>(it.targetUrl);
}; };
_buttonSupport.setOnClickListener { _monetization.onSupportTap.subscribe {
val author = video?.author ?: _searchVideo?.author; _container_content_support.setPolycentricProfile(_polycentricProfile?.profile, false);
author?.let { fragment.navigate<ChannelFragment>(it).selectTab(2); }; switchContentView(_container_content_support);
fragment.lifecycleScope.launch {
delay(100);
fragment.minimizeVideoDetail();
};
}; };
_buttonStore.setOnClickListener { _monetization.onStoreTap.subscribe {
_polycentricProfile?.profile?.systemState?.store?.let { _polycentricProfile?.profile?.systemState?.store?.let {
try { try {
val uri = Uri.parse(it); val uri = Uri.parse(it);
@@ -349,6 +352,13 @@ class VideoDetailView : ConstraintLayout {
} }
}; };
_player.attachPlayer();
_container_content_liveChat.onRaidNow.subscribe {
StatePlayer.instance.clearQueue();
fragment.navigate<VideoDetailFragment>(it.targetUrl);
};
StateApp.instance.preventPictureInPicture.subscribe(this) { StateApp.instance.preventPictureInPicture.subscribe(this) {
Logger.i(TAG, "StateApp.instance.preventPictureInPicture.subscribe preventPictureInPicture = true"); Logger.i(TAG, "StateApp.instance.preventPictureInPicture.subscribe preventPictureInPicture = true");
preventPictureInPicture = true; preventPictureInPicture = true;
@@ -415,7 +425,7 @@ class VideoDetailView : ConstraintLayout {
_layoutSkip.visibility = VISIBLE; _layoutSkip.visibility = VISIBLE;
} }
else if(chapter?.type == ChapterType.SKIP) { else if(chapter?.type == ChapterType.SKIP) {
_player.seekTo(chapter.timeEnd.toLong() * 1000); _player.seekTo((chapter.timeEnd * 1000).toLong());
UIDialogs.toast(context, "Skipped chapter [${chapter.name}]", false); UIDialogs.toast(context, "Skipped chapter [${chapter.name}]", false);
} }
} }
@@ -494,8 +504,14 @@ class VideoDetailView : ConstraintLayout {
updatePillButtonVisibilities(); updatePillButtonVisibilities();
StateCasting.instance.onActiveDevicePlayChanged.subscribe(this) { StateCasting.instance.onActiveDevicePlayChanged.subscribe(this) {
if (StateCasting.instance.activeDevice != null) { val activeDevice = StateCasting.instance.activeDevice;
if (activeDevice != null) {
handlePlayChanged(it); handlePlayChanged(it);
val v = video;
if (!it && v != null && v.duration - activeDevice.time.toLong() < 2L) {
nextVideo();
}
} }
}; };
@@ -539,6 +555,7 @@ class VideoDetailView : ConstraintLayout {
_container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_liveChat.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_queue.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_queue.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_replies.onClose.subscribe { switchContentView(_container_content_main); }; _container_content_replies.onClose.subscribe { switchContentView(_container_content_main); };
_container_content_support.onClose.subscribe { switchContentView(_container_content_main); };
_description_viewMore.setOnClickListener { _description_viewMore.setOnClickListener {
switchContentView(_container_content_description); switchContentView(_container_content_description);
@@ -589,6 +606,8 @@ class VideoDetailView : ConstraintLayout {
_lastSubtitleSource = null; _lastSubtitleSource = null;
video = null; video = null;
_playbackTracker = null; _playbackTracker = null;
Logger.i(TAG, "Keep screen on unset onClose")
fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}; };
_layoutResume.setOnClickListener { _layoutResume.setOnClickListener {
@@ -604,13 +623,13 @@ class VideoDetailView : ConstraintLayout {
_layoutSkip.setOnClickListener { _layoutSkip.setOnClickListener {
val currentChapter = _player.getCurrentChapter(_player.position); val currentChapter = _player.getCurrentChapter(_player.position);
if(currentChapter?.type == ChapterType.SKIPPABLE) { if(currentChapter?.type == ChapterType.SKIPPABLE) {
_player.seekTo(currentChapter.timeEnd.toLong() * 1000); _player.seekTo((currentChapter.timeEnd * 1000).toLong());
} }
} }
} }
val _trackingUpdateTimeLock = Object(); val _trackingUpdateTimeLock = Object();
val _trackingUpdateInterval = 3000; val _trackingUpdateInterval = 2500;
var _trackingLastUpdateTime = System.currentTimeMillis(); var _trackingLastUpdateTime = System.currentTimeMillis();
var _trackingLastPosition: Long = 0; var _trackingLastPosition: Long = 0;
var _trackingLastVideo: IPlatformVideoDetails? = null; var _trackingLastVideo: IPlatformVideoDetails? = null;
@@ -806,7 +825,7 @@ class VideoDetailView : ConstraintLayout {
when (Settings.instance.playback.backgroundPlay) { when (Settings.instance.playback.backgroundPlay) {
0 -> handlePause(); 0 -> handlePause();
1 -> { 1 -> {
if(!(video?.isLive ?: false)) if(!(video?.isLive ?: false) && Settings.instance.playback.backgroundSwitchToAudio)
_player.switchToAudioMode(); _player.switchToAudioMode();
StatePlayer.instance.startOrUpdateMediaSession(context, video); StatePlayer.instance.startOrUpdateMediaSession(context, video);
} }
@@ -841,6 +860,7 @@ class VideoDetailView : ConstraintLayout {
_container_content_replies.cleanup(); _container_content_replies.cleanup();
_container_content_queue.cleanup(); _container_content_queue.cleanup();
_container_content_description.cleanup(); _container_content_description.cleanup();
_container_content_support.cleanup();
StateCasting.instance.onActiveDevicePlayChanged.remove(this); StateCasting.instance.onActiveDevicePlayChanged.remove(this);
StateCasting.instance.onActiveDeviceTimeChanged.remove(this); StateCasting.instance.onActiveDeviceTimeChanged.remove(this);
StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this); StateCasting.instance.onActiveDeviceConnectionStateChanged.remove(this);
@@ -1025,10 +1045,32 @@ class VideoDetailView : ConstraintLayout {
_player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed()); _player.setPlaybackRate(Settings.instance.playback.getDefaultPlaybackSpeed());
} }
val video = if(videoDetail is VideoLocal) var videoLocal: VideoLocal? = null;
videoDetail; var video: IPlatformVideoDetails? = null;
else //TODO: Update cached video if it exists with video
StateDownloads.instance.getCachedVideo(videoDetail.id) ?: videoDetail; if(videoDetail is VideoLocal) {
videoLocal = videoDetail;
video = videoDetail;
val videoTask = StatePlatform.instance.getContentDetails(videoDetail.url);
videoTask.invokeOnCompletion { ex ->
if(ex != null) {
Logger.e(TAG, "Failed to fetch live video for offline video", ex);
return@invokeOnCompletion;
}
val result = videoTask.getCompleted();
if(this.video == videoDetail && result != null && result is IPlatformVideoDetails) {
this.video = result;
fragment.lifecycleScope.launch(Dispatchers.Main) {
updateQualitySourcesOverlay(result, videoLocal);
}
}
};
}
else { //TODO: Update cached video if it exists with video
videoLocal = StateDownloads.instance.getCachedVideo(videoDetail.id);
video = videoDetail;
}
this.videoLocal = videoLocal;
this.video = video; this.video = video;
this._playbackTracker = null; this._playbackTracker = null;
@@ -1042,6 +1084,7 @@ class VideoDetailView : ConstraintLayout {
} }
catch(ex: Throwable) { catch(ex: Throwable) {
Logger.e(TAG, "Failed to get chapters", ex); Logger.e(TAG, "Failed to get chapters", ex);
_player.setChapters(null);
/*withContext(Dispatchers.Main) { /*withContext(Dispatchers.Main) {
UIDialogs.toast(context, "Failed to get chapters\n" + ex.message); UIDialogs.toast(context, "Failed to get chapters\n" + ex.message);
@@ -1062,9 +1105,13 @@ class VideoDetailView : ConstraintLayout {
me._playbackTracker = tracker; me._playbackTracker = tracker;
} }
catch(ex: Throwable) { catch(ex: Throwable) {
withContext(Dispatchers.Main) { Logger.e(TAG, "Playback tracker failed", ex);
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_get_playback_tracker), ex); if(me.video?.isLive == true) withContext(Dispatchers.Main) {
UIDialogs.toast(context, context.getString(R.string.failed_to_get_playback_tracker));
}; };
else withContext(Dispatchers.Main) {
UIDialogs.showGeneralErrorDialog(context, context.getString(R.string.failed_to_get_playback_tracker), ex);
}
} }
}; };
} }
@@ -1074,7 +1121,7 @@ class VideoDetailView : ConstraintLayout {
_player.setMetadata(video.name, video.author.name); _player.setMetadata(video.name, video.author.name);
_toggleCommentType.setValue(false, false); _toggleCommentType.setValue(Settings.instance.comments.defaultCommentSection == 1, false);
updateCommentType(true); updateCommentType(true);
//UI //UI
@@ -1088,6 +1135,14 @@ class VideoDetailView : ConstraintLayout {
(_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_2).toInt(), 0, 0); (_channelName.layoutParams as MarginLayoutParams).setMargins(0, (DP_2).toInt(), 0, 0);
} }
video.author.let {
if(it is PlatformAuthorMembershipLink && !it.membershipUrl.isNullOrEmpty())
_monetization.setPlatformMembership(video.id.pluginId, it.membershipUrl);
else
_monetization.setPlatformMembership(null, null);
}
_minimize_title.text = video.name; _minimize_title.text = video.name;
_minimize_meta.text = video.author.name; _minimize_meta.text = video.author.name;
@@ -1153,7 +1208,7 @@ class VideoDetailView : ConstraintLayout {
fragment.lifecycleScope.launch(Dispatchers.IO) { fragment.lifecycleScope.launch(Dispatchers.IO) {
try { try {
Logger.i(TAG, "Started backfill"); Logger.i(TAG, "Started backfill");
args.processHandle.fullyBackfillServers(); args.processHandle.fullyBackfillServersAnnounceExceptions();
Logger.i(TAG, "Finished backfill"); Logger.i(TAG, "Finished backfill");
} catch (e: Throwable) { } catch (e: Throwable) {
Logger.e(TAG, "Failed to backfill servers", e) Logger.e(TAG, "Failed to backfill servers", e)
@@ -1207,7 +1262,7 @@ class VideoDetailView : ConstraintLayout {
//Overlay //Overlay
updateQualitySourcesOverlay(video); updateQualitySourcesOverlay(video, videoLocal);
setLoading(false); setLoading(false);
@@ -1458,6 +1513,7 @@ class VideoDetailView : ConstraintLayout {
_overlay_quality_selector?.selectOption("audio", _lastAudioSource); _overlay_quality_selector?.selectOption("audio", _lastAudioSource);
_overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource); _overlay_quality_selector?.selectOption("subtitles", _lastSubtitleSource);
_overlay_quality_selector?.show(); _overlay_quality_selector?.show();
_slideUpOverlay = _overlay_quality_selector;
} }
fun prevVideo() { fun prevVideo() {
@@ -1485,9 +1541,9 @@ class VideoDetailView : ConstraintLayout {
//Quality Selector data //Quality Selector data
private fun updateQualityFormatsOverlay(liveStreamVideoFormats : List<Format>?, liveStreamAudioFormats : List<Format>?) { private fun updateQualityFormatsOverlay(liveStreamVideoFormats : List<Format>?, liveStreamAudioFormats : List<Format>?) {
val v = video ?: return; val v = video ?: return;
updateQualitySourcesOverlay(v, liveStreamVideoFormats, liveStreamAudioFormats); updateQualitySourcesOverlay(v, videoLocal, liveStreamVideoFormats, liveStreamAudioFormats);
} }
private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, liveStreamVideoFormats: List<Format>? = null, liveStreamAudioFormats: List<Format>? = null) { private fun updateQualitySourcesOverlay(videoDetails: IPlatformVideoDetails?, videoLocal: VideoLocal? = null, liveStreamVideoFormats: List<Format>? = null, liveStreamAudioFormats: List<Format>? = null) {
Logger.i(TAG, "updateQualitySourcesOverlay"); Logger.i(TAG, "updateQualitySourcesOverlay");
val video: IPlatformVideoDetails?; val video: IPlatformVideoDetails?;
@@ -1495,24 +1551,35 @@ class VideoDetailView : ConstraintLayout {
val localAudioSource: List<LocalAudioSource>?; val localAudioSource: List<LocalAudioSource>?;
val localSubtitleSources: List<LocalSubtitleSource>?; val localSubtitleSources: List<LocalSubtitleSource>?;
val videoSources: List<IVideoSource>?;
val audioSources: List<IAudioSource>?;
if(videoDetails is VideoLocal) { if(videoDetails is VideoLocal) {
video = videoDetails.videoSerialized; video = videoLocal?.videoSerialized;
localVideoSources = videoDetails.videoSource.toList(); localVideoSources = videoDetails.videoSource.toList();
localAudioSource = videoDetails.audioSource.toList(); localAudioSource = videoDetails.audioSource.toList();
localSubtitleSources = videoDetails.subtitlesSources.toList(); localSubtitleSources = videoDetails.subtitlesSources.toList();
videoSources = null
audioSources = null;
} }
else { else {
video = videoDetails; video = videoDetails;
localVideoSources = null; videoSources = video?.video?.videoSources?.toList();
localAudioSource = null; audioSources = if(video?.video?.isUnMuxed == true)
localSubtitleSources = null; (video.video as VideoUnMuxedSourceDescriptor).audioSources.toList()
else null
if(videoLocal != null) {
localVideoSources = videoLocal.videoSource.toList();
localAudioSource = videoLocal.audioSource.toList();
localSubtitleSources = videoLocal.subtitlesSources.toList();
}
else {
localVideoSources = null;
localAudioSource = null;
localSubtitleSources = null;
}
} }
val videoSources = video?.video?.videoSources?.toList();
val audioSources = if(video?.video?.isUnMuxed == true)
(video.video as VideoUnMuxedSourceDescriptor).audioSources.toList()
else null
val bestVideoSources = videoSources?.map { it.height * it.width } val bestVideoSources = videoSources?.map { it.height * it.width }
?.distinct() ?.distinct()
?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) } ?.map { x -> VideoHelper.selectBestVideoSource(videoSources.filter { x == it.height * it.width }, -1, FutoVideoPlayerBase.PREFERED_VIDEO_CONTAINERS) }
@@ -1540,7 +1607,7 @@ class VideoDetailView : ConstraintLayout {
if(localVideoSources?.isNotEmpty() == true) if(localVideoSources?.isNotEmpty() == true)
SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video", SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video",
*localVideoSources.stream() *localVideoSources
.map { .map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, "${it.width}x${it.height}", it, SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, "${it.width}x${it.height}", it,
{ handleSelectVideoTrack(it) }); { handleSelectVideoTrack(it) });
@@ -1548,7 +1615,7 @@ class VideoDetailView : ConstraintLayout {
else null, else null,
if(localAudioSource?.isNotEmpty() == true) if(localAudioSource?.isNotEmpty() == true)
SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio", SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio",
*localAudioSource.stream() *localAudioSource
.map { .map {
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it, SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
{ handleSelectAudioTrack(it) }); { handleSelectAudioTrack(it) });
@@ -1564,7 +1631,7 @@ class VideoDetailView : ConstraintLayout {
else null, else null,
if(liveStreamVideoFormats?.isEmpty() == false) if(liveStreamVideoFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video", SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video",
*liveStreamVideoFormats.stream() *liveStreamVideoFormats
.map { .map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it?.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it, SlideUpMenuItem(this.context, R.drawable.ic_movie, it?.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it,
{ _player.selectVideoTrack(it.height) }); { _player.selectVideoTrack(it.height) });
@@ -1572,7 +1639,7 @@ class VideoDetailView : ConstraintLayout {
else null, else null,
if(liveStreamAudioFormats?.isEmpty() == false) if(liveStreamAudioFormats?.isEmpty() == false)
SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio", SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
*liveStreamAudioFormats.stream() *liveStreamAudioFormats
.map { .map {
SlideUpMenuItem(this.context, R.drawable.ic_music, "${it?.label ?: it.containerMimeType} ${it.bitrate}", "", it, SlideUpMenuItem(this.context, R.drawable.ic_music, "${it?.label ?: it.containerMimeType} ${it.bitrate}", "", it,
{ _player.selectAudioTrack(it.bitrate) }); { _player.selectAudioTrack(it.bitrate) });
@@ -1581,7 +1648,7 @@ class VideoDetailView : ConstraintLayout {
if(bestVideoSources.isNotEmpty()) if(bestVideoSources.isNotEmpty())
SlideUpMenuGroup(this.context, context.getString(R.string.video), "video", SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
*bestVideoSources.stream() *bestVideoSources
.map { .map {
SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it, SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it,
{ handleSelectVideoTrack(it) }); { handleSelectVideoTrack(it) });
@@ -1589,7 +1656,7 @@ class VideoDetailView : ConstraintLayout {
else null, else null,
if(bestAudioSources.isNotEmpty()) if(bestAudioSources.isNotEmpty())
SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio", SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
*bestAudioSources.stream() *bestAudioSources
.map { .map {
SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it, SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
{ handleSelectAudioTrack(it) }); { handleSelectAudioTrack(it) });
@@ -1659,14 +1726,28 @@ class VideoDetailView : ConstraintLayout {
if(playing) { if(playing) {
_minimize_controls_pause.visibility = View.VISIBLE; _minimize_controls_pause.visibility = View.VISIBLE;
_minimize_controls_play.visibility = View.GONE; _minimize_controls_play.visibility = View.GONE;
if (_isCasting) {
if (Settings.instance.casting.keepScreenOn) {
Logger.i(TAG, "Keep screen on set handlePlayChanged casting")
fragment.activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
} else {
Logger.i(TAG, "Keep screen on set handlePlayChanged player")
fragment.activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
} }
else { else {
_minimize_controls_pause.visibility = View.GONE; _minimize_controls_pause.visibility = View.GONE;
_minimize_controls_play.visibility = View.VISIBLE; _minimize_controls_play.visibility = View.VISIBLE;
Logger.i(TAG, "Keep screen on unset handlePlayChanged")
fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} }
isPlaying = playing;
onPlayChanged.emit(playing); onPlayChanged.emit(playing);
updateTracker(_player.position, playing, true); updateTracker(lastPositionMilliseconds, playing, true);
} }
private fun handleSelectVideoTrack(videoSource: IVideoSource) { private fun handleSelectVideoTrack(videoSource: IVideoSource) {
@@ -1798,11 +1879,12 @@ class VideoDetailView : ConstraintLayout {
private fun setCastEnabled(isCasting: Boolean) { private fun setCastEnabled(isCasting: Boolean) {
Logger.i(TAG, "setCastEnabled(isCasting=$isCasting)") Logger.i(TAG, "setCastEnabled(isCasting=$isCasting)")
video?.let { updateQualitySourcesOverlay(it); }; video?.let { updateQualitySourcesOverlay(it, videoLocal); };
_isCasting = isCasting; _isCasting = isCasting;
if(isCasting) { if(isCasting) {
setFullscreen(false);
_player.stop(); _player.stop();
_player.hideControls(false); _player.hideControls(false);
_cast.visibility = View.VISIBLE; _cast.visibility = View.VISIBLE;
@@ -2009,7 +2091,8 @@ class VideoDetailView : ConstraintLayout {
StatePlaylists.instance.updateHistoryPosition(v, true, (positionMilliseconds.toFloat() / 1000.0f).toLong()); StatePlaylists.instance.updateHistoryPosition(v, true, (positionMilliseconds.toFloat() / 1000.0f).toLong());
_lastPositionSaveTime = currentTime; _lastPositionSaveTime = currentTime;
} }
updateTracker(positionMilliseconds, _player.playing, false);
updateTracker(positionMilliseconds, isPlaying, false);
} }
private fun updateTracker(positionMs: Long, isPlaying: Boolean, forceUpdate: Boolean = false) { private fun updateTracker(positionMs: Long, isPlaying: Boolean, forceUpdate: Boolean = false) {
@@ -2090,12 +2173,7 @@ class VideoDetailView : ConstraintLayout {
_creatorThumbnail.setHarborAvailable(profile != null, animate); _creatorThumbnail.setHarborAvailable(profile != null, animate);
} }
if (profile != null) { _monetization.setPolycentricProfile(cachedPolycentricProfile, animate);
_channelName.text = cachedPolycentricProfile.profile.systemState.username;
_layoutMonetization.visibility = View.VISIBLE;
} else {
_layoutMonetization.visibility = View.GONE;
}
} }
fun setProgressBarOverlayed(isOverlayed: Boolean?) { fun setProgressBarOverlayed(isOverlayed: Boolean?) {
@@ -1,5 +1,7 @@
package com.futo.platformplayer.images; package com.futo.platformplayer.images;
import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import com.bumptech.glide.Priority; import com.bumptech.glide.Priority;
@@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.models.channels.SerializedChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent 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.contents.IPlatformContentDetails
import com.futo.platformplayer.getNowDiffDays import com.futo.platformplayer.getNowDiffDays
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StatePlatform
import com.futo.platformplayer.states.StateSubscriptions import com.futo.platformplayer.states.StateSubscriptions
@@ -53,10 +54,12 @@ class Subscription {
this.channel = channel; this.channel = channel;
} }
fun shouldFetchVideos() = true; fun shouldFetchVideos() = doFetchVideos &&
fun shouldFetchStreams() = doFetchStreams && lastLiveStream.getNowDiffDays() < 7; (lastVideo.getNowDiffDays() < 30 || lastVideoUpdate.getNowDiffDays() >= 1) &&
fun shouldFetchLiveStreams() = doFetchLive && lastLiveStream.getNowDiffDays() < 14; (lastVideo.getNowDiffDays() < 180 || lastVideoUpdate.getNowDiffDays() >= 3);
fun shouldFetchPosts() = doFetchPosts && lastPost.getNowDiffDays() < 2; fun shouldFetchStreams() = doFetchStreams && (lastLiveStream.getNowDiffDays() < 7);
fun shouldFetchLiveStreams() = doFetchLive && (lastLiveStream.getNowDiffDays() < 14);
fun shouldFetchPosts() = doFetchPosts && (lastPost.getNowDiffDays() < 5);
fun getClient() = StatePlatform.instance.getChannelClientOrNull(channel.url); fun getClient() = StatePlatform.instance.getChannelClientOrNull(channel.url);
@@ -103,30 +106,47 @@ class Subscription {
else { else {
interval = 5; interval = 5;
mostRecent = null; mostRecent = null;
Logger.i("Subscription", "Subscription [${channel.name}]:${type} no results found");
} }
when(type) { when(type) {
ResultCapabilities.TYPE_VIDEOS -> { ResultCapabilities.TYPE_VIDEOS -> {
uploadInterval = interval; uploadInterval = interval;
if(mostRecent != null) if(mostRecent != null)
lastVideo = mostRecent; lastVideo = mostRecent;
else if(lastVideo.year > 3000)
lastVideo = OffsetDateTime.MIN;
lastVideoUpdate = OffsetDateTime.now(); lastVideoUpdate = OffsetDateTime.now();
} }
ResultCapabilities.TYPE_MIXED -> { ResultCapabilities.TYPE_MIXED -> {
uploadInterval = interval; uploadInterval = interval;
if(mostRecent != null) if(mostRecent != null)
lastVideo = mostRecent; lastVideo = mostRecent;
else if(lastVideo.year > 3000)
lastVideo = OffsetDateTime.MIN;
lastVideoUpdate = OffsetDateTime.now();
}
ResultCapabilities.TYPE_SUBSCRIPTIONS -> {
uploadInterval = interval;
if(mostRecent != null)
lastVideo = mostRecent;
else if(lastVideo.year > 3000)
lastVideo = OffsetDateTime.MIN;
lastVideoUpdate = OffsetDateTime.now(); lastVideoUpdate = OffsetDateTime.now();
} }
ResultCapabilities.TYPE_STREAMS -> { ResultCapabilities.TYPE_STREAMS -> {
uploadStreamInterval = interval; uploadStreamInterval = interval;
if(mostRecent != null) if(mostRecent != null)
lastLiveStream = mostRecent; lastLiveStream = mostRecent;
else if(lastLiveStream.year > 3000)
lastLiveStream = OffsetDateTime.MIN;
lastStreamUpdate = OffsetDateTime.now(); lastStreamUpdate = OffsetDateTime.now();
} }
ResultCapabilities.TYPE_POSTS -> { ResultCapabilities.TYPE_POSTS -> {
uploadPostInterval = interval; uploadPostInterval = interval;
if(mostRecent != null) if(mostRecent != null)
lastPost = mostRecent; lastPost = mostRecent;
else if(lastPost.year > 3000)
lastPost = OffsetDateTime.MIN;
lastPostUpdate = OffsetDateTime.now(); lastPostUpdate = OffsetDateTime.now();
} }
} }
@@ -0,0 +1,342 @@
package com.futo.platformplayer.parsers
import com.futo.platformplayer.api.http.ManagedHttpClient
import com.futo.platformplayer.toYesNo
import com.futo.platformplayer.yesNoToBoolean
import java.net.URI
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
class HLS {
companion object {
fun downloadAndParseMasterPlaylist(client: ManagedHttpClient, sourceUrl: String): MasterPlaylist {
val masterPlaylistResponse = client.get(sourceUrl)
check(masterPlaylistResponse.isOk) { "Failed to get master playlist: ${masterPlaylistResponse.code}" }
val masterPlaylistContent = masterPlaylistResponse.body?.string()
?: throw Exception("Master playlist content is empty")
val baseUrl = URI(sourceUrl).resolve("./").toString()
val variantPlaylists = mutableListOf<VariantPlaylistReference>()
val mediaRenditions = mutableListOf<MediaRendition>()
val sessionDataList = mutableListOf<SessionData>()
var independentSegments = false
masterPlaylistContent.lines().forEachIndexed { index, line ->
when {
line.startsWith("#EXT-X-STREAM-INF") -> {
val nextLine = masterPlaylistContent.lines().getOrNull(index + 1)
?: throw Exception("Expected URI following #EXT-X-STREAM-INF, found none")
val url = resolveUrl(baseUrl, nextLine)
variantPlaylists.add(VariantPlaylistReference(url, parseStreamInfo(line)))
}
line.startsWith("#EXT-X-MEDIA") -> {
mediaRenditions.add(parseMediaRendition(client, line, baseUrl))
}
line == "#EXT-X-INDEPENDENT-SEGMENTS" -> {
independentSegments = true
}
line.startsWith("#EXT-X-SESSION-DATA") -> {
val sessionData = parseSessionData(line)
sessionDataList.add(sessionData)
}
}
}
return MasterPlaylist(variantPlaylists, mediaRenditions, sessionDataList, independentSegments)
}
fun downloadAndParseVariantPlaylist(client: ManagedHttpClient, sourceUrl: String): VariantPlaylist {
val response = client.get(sourceUrl)
check(response.isOk) { "Failed to get variant playlist: ${response.code}" }
val content = response.body?.string()
?: throw Exception("Variant playlist content is empty")
val lines = content.lines()
val version = lines.find { it.startsWith("#EXT-X-VERSION:") }?.substringAfter(":")?.toIntOrNull() ?: 3
val targetDuration = lines.find { it.startsWith("#EXT-X-TARGETDURATION:") }?.substringAfter(":")?.toIntOrNull()
?: throw Exception("Target duration not found in variant playlist")
val mediaSequence = lines.find { it.startsWith("#EXT-X-MEDIA-SEQUENCE:") }?.substringAfter(":")?.toLongOrNull() ?: 0
val discontinuitySequence = lines.find { it.startsWith("#EXT-X-DISCONTINUITY-SEQUENCE:") }?.substringAfter(":")?.toIntOrNull() ?: 0
val programDateTime = lines.find { it.startsWith("#EXT-X-PROGRAM-DATE-TIME:") }?.substringAfter(":")?.let {
ZonedDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME)
}
val playlistType = lines.find { it.startsWith("#EXT-X-PLAYLIST-TYPE:") }?.substringAfter(":")
val segments = mutableListOf<Segment>()
var currentSegment: MediaSegment? = null
lines.forEach { line ->
when {
line.startsWith("#EXTINF:") -> {
val duration = line.substringAfter(":").substringBefore(",").toDoubleOrNull()
?: throw Exception("Invalid segment duration format")
currentSegment = MediaSegment(duration = duration)
}
line == "#EXT-X-DISCONTINUITY" -> {
segments.add(DiscontinuitySegment())
}
line =="#EXT-X-ENDLIST" -> {
segments.add(EndListSegment())
}
else -> {
currentSegment?.let {
it.uri = resolveUrl(sourceUrl, line)
segments.add(it)
}
currentSegment = null
}
}
}
return VariantPlaylist(version, targetDuration, mediaSequence, discontinuitySequence, programDateTime, playlistType, segments)
}
private fun resolveUrl(baseUrl: String, url: String): String {
val baseUri = URI(baseUrl)
val urlUri = URI(url)
return if (urlUri.isAbsolute) {
url
} else {
val resolvedUri = baseUri.resolve(urlUri)
resolvedUri.toString()
}
}
private fun parseStreamInfo(content: String): StreamInfo {
val attributes = parseAttributes(content)
return StreamInfo(
bandwidth = attributes["BANDWIDTH"]?.toIntOrNull(),
resolution = attributes["RESOLUTION"],
codecs = attributes["CODECS"],
frameRate = attributes["FRAME-RATE"],
videoRange = attributes["VIDEO-RANGE"],
audio = attributes["AUDIO"],
video = attributes["VIDEO"],
subtitles = attributes["SUBTITLES"],
closedCaptions = attributes["CLOSED-CAPTIONS"]
)
}
private fun parseMediaRendition(client: ManagedHttpClient, line: String, baseUrl: String): MediaRendition {
val attributes = parseAttributes(line)
val uri = attributes["URI"]?.let { resolveUrl(baseUrl, it) }
return MediaRendition(
type = attributes["TYPE"],
uri = uri,
groupID = attributes["GROUP-ID"],
language = attributes["LANGUAGE"],
name = attributes["NAME"],
isDefault = attributes["DEFAULT"]?.yesNoToBoolean(),
isAutoSelect = attributes["AUTOSELECT"]?.yesNoToBoolean(),
isForced = attributes["FORCED"]?.yesNoToBoolean()
)
}
private fun parseSessionData(line: String): SessionData {
val attributes = parseAttributes(line)
val dataId = attributes["DATA-ID"]!!
val value = attributes["VALUE"]!!
return SessionData(dataId, value)
}
private fun parseAttributes(content: String): Map<String, String> {
val attributes = mutableMapOf<String, String>()
val attributePairs = content.substringAfter(":").splitToSequence(',')
var currentPair = StringBuilder()
for (pair in attributePairs) {
currentPair.append(pair)
if (currentPair.count { it == '\"' } % 2 == 0) { // Check if the number of quotes is even
val (key, value) = currentPair.toString().split('=')
attributes[key.trim()] = value.trim().removeSurrounding("\"")
currentPair = StringBuilder() // Reset for the next attribute
} else {
currentPair.append(',') // Continue building the current attribute pair
}
}
return attributes
}
private val _quoteList = listOf("GROUP-ID", "NAME", "URI", "CODECS", "AUDIO", "VIDEO")
private fun shouldQuote(key: String, value: String?): Boolean {
if (value == null)
return false;
if (value.contains(','))
return true;
return _quoteList.contains(key)
}
private fun appendAttributes(stringBuilder: StringBuilder, vararg attributes: Pair<String, String?>) {
attributes.filter { it.second != null }
.joinToString(",") {
val value = it.second
"${it.first}=${if (shouldQuote(it.first, it.second)) "\"$value\"" else value}"
}
.let { if (it.isNotEmpty()) stringBuilder.append(it) }
}
}
data class SessionData(
val dataId: String,
val value: String
) {
fun toM3U8Line(): String = buildString {
append("#EXT-X-SESSION-DATA:")
appendAttributes(this,
"DATA-ID" to dataId,
"VALUE" to value
)
append("\n")
}
}
data class StreamInfo(
val bandwidth: Int?,
val resolution: String?,
val codecs: String?,
val frameRate: String?,
val videoRange: String?,
val audio: String?,
val video: String?,
val subtitles: String?,
val closedCaptions: String?
)
data class MediaRendition(
val type: String?,
val uri: String?,
val groupID: String?,
val language: String?,
val name: String?,
val isDefault: Boolean?,
val isAutoSelect: Boolean?,
val isForced: Boolean?
) {
fun toM3U8Line(): String = buildString {
append("#EXT-X-MEDIA:")
appendAttributes(this,
"TYPE" to type,
"URI" to uri,
"GROUP-ID" to groupID,
"LANGUAGE" to language,
"NAME" to name,
"DEFAULT" to isDefault.toYesNo(),
"AUTOSELECT" to isAutoSelect.toYesNo(),
"FORCED" to isForced.toYesNo()
)
append("\n")
}
}
data class MasterPlaylist(
val variantPlaylistsRefs: List<VariantPlaylistReference>,
val mediaRenditions: List<MediaRendition>,
val sessionDataList: List<SessionData>,
val independentSegments: Boolean
) {
fun buildM3U8(): String {
val builder = StringBuilder()
builder.append("#EXTM3U\n")
if (independentSegments) {
builder.append("#EXT-X-INDEPENDENT-SEGMENTS\n")
}
mediaRenditions.forEach { rendition ->
builder.append(rendition.toM3U8Line())
}
variantPlaylistsRefs.forEach { variant ->
builder.append(variant.toM3U8Line())
}
sessionDataList.forEach { data ->
builder.append(data.toM3U8Line())
}
return builder.toString()
}
}
data class VariantPlaylistReference(val url: String, val streamInfo: StreamInfo) {
fun toM3U8Line(): String = buildString {
append("#EXT-X-STREAM-INF:")
appendAttributes(this,
"BANDWIDTH" to streamInfo.bandwidth?.toString(),
"RESOLUTION" to streamInfo.resolution,
"CODECS" to streamInfo.codecs,
"FRAME-RATE" to streamInfo.frameRate,
"VIDEO-RANGE" to streamInfo.videoRange,
"AUDIO" to streamInfo.audio,
"VIDEO" to streamInfo.video,
"SUBTITLES" to streamInfo.subtitles,
"CLOSED-CAPTIONS" to streamInfo.closedCaptions
)
append("\n$url\n")
}
}
data class VariantPlaylist(
val version: Int,
val targetDuration: Int,
val mediaSequence: Long,
val discontinuitySequence: Int,
val programDateTime: ZonedDateTime?,
val playlistType: String?,
val segments: List<Segment>
) {
fun buildM3U8(): String = buildString {
append("#EXTM3U\n")
append("#EXT-X-VERSION:$version\n")
append("#EXT-X-TARGETDURATION:$targetDuration\n")
append("#EXT-X-MEDIA-SEQUENCE:$mediaSequence\n")
append("#EXT-X-DISCONTINUITY-SEQUENCE:$discontinuitySequence\n")
playlistType?.let {
append("#EXT-X-PLAYLIST-TYPE:$it\n")
}
programDateTime?.let {
append("#EXT-X-PROGRAM-DATE-TIME:${it.format(DateTimeFormatter.ISO_DATE_TIME)}\n")
}
segments.forEach { segment ->
append(segment.toM3U8Line())
}
}
}
abstract class Segment {
abstract fun toM3U8Line(): String
}
data class MediaSegment (
val duration: Double,
var uri: String = ""
) : Segment() {
override fun toM3U8Line(): String = buildString {
append("#EXTINF:${duration},\n")
append(uri + "\n")
}
}
class DiscontinuitySegment : Segment() {
override fun toM3U8Line(): String = buildString {
append("#EXT-X-DISCONTINUITY\n")
}
}
class EndListSegment : Segment() {
override fun toM3U8Line(): String = buildString {
append("#EXT-X-ENDLIST\n")
}
}
}
@@ -0,0 +1,66 @@
package com.futo.platformplayer.parsers
import com.futo.platformplayer.api.http.server.HttpHeaders
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
import com.futo.platformplayer.readHttpHeaderBytes
import java.io.ByteArrayInputStream
import java.io.InputStream
class HttpResponseParser : AutoCloseable {
private val _inputStream: InputStream;
var head: String = "";
var headers: HttpHeaders = HttpHeaders();
var contentType: String? = null;
var transferEncoding: String? = null;
var location: String? = null;
var contentLength: Long = -1L;
var statusCode: Int = -1;
constructor(inputStream: InputStream) {
_inputStream = inputStream;
val headerBytes = inputStream.readHttpHeaderBytes()
ByteArrayInputStream(headerBytes).use {
val reader = it.bufferedReader(Charsets.UTF_8)
head = reader.readLine() ?: throw EmptyRequestException("No head found");
val statusLineParts = head.split(" ")
if (statusLineParts.size < 3) {
throw IllegalStateException("Invalid status line")
}
statusCode = statusLineParts[1].toInt()
while (true) {
val line = reader.readLine();
val headerEndIndex = line.indexOf(":");
if (headerEndIndex == -1)
break;
val headerKey = line.substring(0, headerEndIndex).lowercase()
val headerValue = line.substring(headerEndIndex + 1).trim();
headers[headerKey] = headerValue;
when(headerKey) {
"content-length" -> contentLength = headerValue.toLong();
"content-type" -> contentType = headerValue;
"transfer-encoding" -> transferEncoding = headerValue;
"location" -> location = headerValue;
}
if(line.isNullOrEmpty())
break;
}
}
}
override fun close() {
_inputStream.close();
}
companion object {
private val TAG = "HttpResponse";
}
}
@@ -39,7 +39,12 @@ class PolycentricCache {
ContentType.USERNAME.value, ContentType.USERNAME.value,
ContentType.DESCRIPTION.value, ContentType.DESCRIPTION.value,
ContentType.STORE.value, ContentType.STORE.value,
ContentType.SERVER.value ContentType.SERVER.value,
ContentType.STORE_DATA.value,
ContentType.PROMOTION_BANNER.value,
ContentType.PROMOTION.value,
ContentType.MEMBERSHIP_URLS.value,
ContentType.DONATION_DESTINATIONS.value
) )
).eventsList.map { e -> SignedEvent.fromProto(e) }; ).eventsList.map { e -> SignedEvent.fromProto(e) };
@@ -0,0 +1,48 @@
package com.futo.platformplayer.receivers
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.futo.platformplayer.Settings
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.states.StateApp
import com.futo.platformplayer.states.StateNotifications
class PlannedNotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
try {
Logger.i(TAG, "Planned Notification received");
if(!Settings.instance.notifications.plannedContentNotification)
return;
if(StateApp.instance.contextOrNull == null)
StateApp.instance.initializeFiles();
val notifs = StateNotifications.instance.getScheduledNotifications(60 * 15, true);
if(!notifs.isEmpty() && context != null) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
val channel = StateNotifications.instance.contentNotifChannel;
notificationManager.createNotificationChannel(channel);
var i = 0;
for (notif in notifs) {
StateNotifications.instance.notifyNewContentWithThumbnail(context, notificationManager, channel, 110 + i, notif);
i++;
}
}
}
catch(ex: Throwable) {
Logger.e(TAG, "Failed PlannedNotificationReceiver.onReceive", ex);
}
}
companion object {
private val TAG = "PlannedNotificationReceiver"
fun getIntent(context: Context): PendingIntent {
return PendingIntent.getBroadcast(context, 110, Intent(context, PlannedNotificationReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE);
}
}
}
@@ -107,7 +107,7 @@ class ExportingService : Service() {
{ {
try{ try{
notifyExport(currentExport); notifyExport(currentExport);
doExport(currentExport); doExport(applicationContext, currentExport);
} }
catch(ex: Throwable) { catch(ex: Throwable) {
Logger.e(TAG, "Failed export [${currentExport.videoLocal.name}]: ${ex.message}", ex); Logger.e(TAG, "Failed export [${currentExport.videoLocal.name}]: ${ex.message}", ex);
@@ -125,13 +125,13 @@ class ExportingService : Service() {
stopService(this); stopService(this);
} }
private suspend fun doExport(export: VideoExport) { private suspend fun doExport(context: Context, export: VideoExport) {
Logger.i(TAG, "Exporting [${export.videoLocal.name}] started"); Logger.i(TAG, "Exporting [${export.videoLocal.name}] started");
export.changeState(VideoExport.State.EXPORTING); export.changeState(VideoExport.State.EXPORTING);
var lastNotifyTime: Long = 0L; var lastNotifyTime: Long = 0L;
val file = export.export { progress -> val file = export.export(context) { progress ->
export.progress = progress; export.progress = progress;
val currentTime = System.currentTimeMillis(); val currentTime = System.currentTimeMillis();
@@ -146,7 +146,7 @@ class ExportingService : Service() {
notifyExport(export); notifyExport(export);
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.path}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") { StateAnnouncement.instance.registerAnnouncement(UUID.randomUUID().toString(), "File exported", "Exported [${file.uri}]", AnnouncementType.SESSION, time = null, category = "download", actionButton = "Open") {
file.share(this@ExportingService); file.share(this@ExportingService);
}; };
} }
@@ -10,6 +10,7 @@ import android.media.AudioFocusRequest
import android.media.AudioManager import android.media.AudioManager
import android.media.AudioManager.OnAudioFocusChangeListener import android.media.AudioManager.OnAudioFocusChangeListener
import android.media.MediaMetadata import android.media.MediaMetadata
import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.SystemClock import android.os.SystemClock
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
@@ -278,7 +279,13 @@ class MediaPlaybackService : Service() {
Logger.i(TAG, "Updating notification bitmap=${if (bitmap != null) "yes" else "no."} channelId=${channel.id} icon=${icon} video=${video?.name ?: ""} playWhenReady=${playWhenReady} session.sessionToken=${session.sessionToken}"); Logger.i(TAG, "Updating notification bitmap=${if (bitmap != null) "yes" else "no."} channelId=${channel.id} icon=${icon} video=${video?.name ?: ""} playWhenReady=${playWhenReady} session.sessionToken=${session.sessionToken}");
startForeground(MEDIA_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// For API 29 and above
startForeground(MEDIA_NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
} else {
// For API 28 and below
startForeground(MEDIA_NOTIF_ID, notif);
}
_notif_last_bitmap = bitmap; _notif_last_bitmap = bitmap;
} }
@@ -111,17 +111,13 @@ class StateApp {
return null; return null;
} }
fun changeExternalDownloadDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) { fun changeExternalDownloadDirectory(context: IWithResultLauncher, onChanged: ((DocumentFile?)->Unit)? = null) {
scopeOrNull?.launch(Dispatchers.Main) {
UIDialogs.toast("External download directory not yet used by export (WIP)");
};
if(context is Context) if(context is Context)
requestDirectoryAccess(context, "Download Exports", "This directory is used to export downloads to for external usage.", null) { requestDirectoryAccess(context, "Download Exports", "This directory is used to export downloads to for external usage.", null) {
if(it != null) if(it != null)
context.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION)); context.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_WRITE_URI_PERMISSION.or(Intent.FLAG_GRANT_READ_URI_PERMISSION));
if(it != null && isValidStorageUri(context, it)) { if(it != null && isValidStorageUri(context, it)) {
Logger.i(TAG, "Changed external download directory: ${it}"); Logger.i(TAG, "Changed external download directory: ${it}");
Settings.instance.storage.storage_general = it.toString(); Settings.instance.storage.storage_download = it.toString();
Settings.instance.save(); Settings.instance.save();
onChanged?.invoke(getExternalDownloadDirectory(context)); onChanged?.invoke(getExternalDownloadDirectory(context));
@@ -239,6 +235,25 @@ class StateApp {
return state; return state;
} }
fun requestFileReadAccess(activity: IWithResultLauncher, path: Uri?, handle: (DocumentFile?)->Unit) {
if(activity is Context) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT);
if(path != null)
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, path);
intent.flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
.or(Intent.FLAG_GRANT_READ_URI_PERMISSION);
activity.launchForResult(intent, 98) {
if(it.resultCode == Activity.RESULT_OK) {
val uri = it.data?.data;
if(uri != null)
handle(DocumentFile.fromSingleUri(activity, uri));
}
else
UIDialogs.showDialogOk(context, R.drawable.ic_security_pred, "No access granted");
};
}
}
fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit) fun requestDirectoryAccess(activity: IWithResultLauncher, name: String, purpose: String? = null, path: Uri?, handle: (Uri?)->Unit)
{ {
if(activity is Context) if(activity is Context)
@@ -335,6 +350,7 @@ class StateApp {
} }
fun mainAppStarting(context: Context) { fun mainAppStarting(context: Context) {
Logger.i(TAG, "MainApp Starting");
initializeFiles(true); initializeFiles(true);
val logFile = File(context.filesDir, "log.txt"); val logFile = File(context.filesDir, "log.txt");
@@ -353,14 +369,18 @@ class StateApp {
Logger.setLogConsumers(listOf(AndroidLogConsumer())); Logger.setLogConsumers(listOf(AndroidLogConsumer()));
} }
StatePayment.instance.initialize(); StatePayment.instance.initialize();
Logger.i(TAG, "MainApp Starting: Initializing [Polycentric]");
StatePolycentric.instance.load(context); StatePolycentric.instance.load(context);
Logger.i(TAG, "MainApp Starting: Initializing [Saved]");
StateSaved.instance.load(); StateSaved.instance.load();
Logger.i(TAG, "MainApp Starting: Initializing [Connectivity]");
displayMetrics = context.resources.displayMetrics; displayMetrics = context.resources.displayMetrics;
ensureConnectivityManager(context); ensureConnectivityManager(context);
Logger.i(TAG, "MainApp Starting: Initializing [Telemetry]");
if (!BuildConfig.DEBUG) { if (!BuildConfig.DEBUG) {
StateTelemetry.instance.initialize(); StateTelemetry.instance.initialize();
StateTelemetry.instance.upload(); StateTelemetry.instance.upload();
@@ -381,11 +401,12 @@ class StateApp {
} }
} }
fun mainAppStarted(context: Context) { fun mainAppStarted(context: Context) {
Logger.i(TAG, "App started"); Logger.i(TAG, "MainApp Started");
//Start loading cache //Start loading cache
instance.scopeOrNull?.launch(Dispatchers.IO) { instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
Logger.i(TAG, "MainApp Started: Initializing [ChannelContentCache]");
val time = measureTimeMillis { val time = measureTimeMillis {
ChannelContentCache.instance; ChannelContentCache.instance;
} }
@@ -400,10 +421,12 @@ class StateApp {
if(SettingsDev.instance.developerMode && SettingsDev.instance.devServerSettings.devServerOnBoot) if(SettingsDev.instance.developerMode && SettingsDev.instance.devServerSettings.devServerOnBoot)
StateDeveloper.instance.runServer(); StateDeveloper.instance.runServer();
Logger.i(TAG, "MainApp Started: Check [Migration (Subscriptions)]");
if(StateSubscriptions.instance.shouldMigrate()) if(StateSubscriptions.instance.shouldMigrate())
StateSubscriptions.instance.tryMigrateIfNecessary(); StateSubscriptions.instance.tryMigrateIfNecessary();
if(Settings.instance.downloads.shouldDownload()) { if(Settings.instance.downloads.shouldDownload()) {
Logger.i(TAG, "MainApp Started: Check [Downloads]");
StateDownloads.instance.checkForOutdatedPlaylists(); StateDownloads.instance.checkForOutdatedPlaylists();
StateDownloads.instance.getDownloadPlaylists(); StateDownloads.instance.getDownloadPlaylists();
@@ -411,8 +434,10 @@ class StateApp {
DownloadService.getOrCreateService(context); DownloadService.getOrCreateService(context);
} }
Logger.i(TAG, "MainApp Started: Check [Exports]");
StateDownloads.instance.checkForExportTodos(); StateDownloads.instance.checkForExportTodos();
Logger.i(TAG, "MainApp Started: Initialize [AutoUpdate]");
val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled(); val autoUpdateEnabled = Settings.instance.autoUpdate.isAutoUpdateEnabled();
val shouldDownload = Settings.instance.autoUpdate.shouldDownload(); val shouldDownload = Settings.instance.autoUpdate.shouldDownload();
val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1; val backgroundDownload = Settings.instance.autoUpdate.backgroundDownload == 1;
@@ -436,6 +461,7 @@ class StateApp {
} }
} }
Logger.i(TAG, "MainApp Started: Initialize [Noisy]");
_receiverBecomingNoisy?.let { _receiverBecomingNoisy?.let {
_receiverBecomingNoisy = null; _receiverBecomingNoisy = null;
context.unregisterReceiver(it); context.unregisterReceiver(it);
@@ -444,6 +470,7 @@ class StateApp {
context.registerReceiver(_receiverBecomingNoisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); context.registerReceiver(_receiverBecomingNoisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
//Migration //Migration
Logger.i(TAG, "MainApp Started: Check [Migrations]");
migrateStores(context, listOf( migrateStores(context, listOf(
StateSubscriptions.instance.toMigrateCheck(), StateSubscriptions.instance.toMigrateCheck(),
StatePlaylists.instance.toMigrateCheck() StatePlaylists.instance.toMigrateCheck()
@@ -451,9 +478,10 @@ class StateApp {
if(Settings.instance.subscriptions.fetchOnAppBoot) { if(Settings.instance.subscriptions.fetchOnAppBoot) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
Logger.i(TAG, "MainApp Started: Fetch [Subscriptions]");
val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount(); val subRequestCounts = StateSubscriptions.instance.getSubscriptionRequestCount();
val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.config.subscriptionRateLimit}" }.joinToString("\n"); val reqCountStr = subRequestCounts.map { " ${it.key.config.name}: ${it.value}/${it.key.getSubscriptionRateLimit()}" }.joinToString("\n");
val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.config.subscriptionRateLimit?.let { rateLimit -> clientCount.value > rateLimit } == true }; val isRateLimitReached = !subRequestCounts.any { clientCount -> clientCount.key.getSubscriptionRateLimit()?.let { rateLimit -> clientCount.value > rateLimit } == true };
if (isRateLimitReached) { if (isRateLimitReached) {
Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}"); Logger.w(TAG, "Subscriptions request on boot, request counts:\n${reqCountStr}");
delay(5000); delay(5000);
@@ -465,9 +493,11 @@ class StateApp {
} }
} }
Logger.i(TAG, "MainApp Started: Initialize [BackgroundWork]");
val interval = Settings.instance.subscriptions.getSubscriptionsBackgroundIntervalMinutes(); val interval = Settings.instance.subscriptions.getSubscriptionsBackgroundIntervalMinutes();
scheduleBackgroundWork(context, interval != 0, interval); scheduleBackgroundWork(context, interval != 0, interval);
Logger.i(TAG, "MainApp Started: Initialize [AutoBackup]");
if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) { if(!Settings.instance.backup.didAskAutoBackup && !Settings.instance.backup.shouldAutomaticBackup()) {
StateAnnouncement.instance.registerAnnouncement("backup", "Set Automatic Backup", "Configure daily backups of your data to restore in case of catastrophic failure.", AnnouncementType.SESSION, null, null, "Configure", { StateAnnouncement.instance.registerAnnouncement("backup", "Set Automatic Backup", "Configure daily backups of your data to restore in case of catastrophic failure.", AnnouncementType.SESSION, null, null, "Configure", {
if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) { if(context is IWithResultLauncher && !Settings.instance.storage.isStorageMainValid(context)) {
@@ -495,6 +525,7 @@ class StateApp {
} }
} }
Logger.i(TAG, "MainApp Started: Initialize [Announcements]");
instance.scopeOrNull?.launch(Dispatchers.IO) { instance.scopeOrNull?.launch(Dispatchers.IO) {
try { try {
StateAnnouncement.instance.loadAnnouncements(); StateAnnouncement.instance.loadAnnouncements();
@@ -513,7 +544,7 @@ class StateApp {
} }
StateAnnouncement.instance.registerDidYouKnow(); StateAnnouncement.instance.registerDidYouKnow();
Logger.i(TAG, "MainApp Started: Finished");
} }
fun mainAppStartedWithExternalFiles(context: Context) { fun mainAppStartedWithExternalFiles(context: Context) {
if(!Settings.instance.didFirstStart) { if(!Settings.instance.didFirstStart) {
@@ -712,6 +743,34 @@ class StateApp {
} }
} }
fun getLocaleContext(baseContext: Context?): Context? {
val locale = getLocaleSetting(baseContext);
try {
if (baseContext != null && locale != null) {
val config = baseContext.resources.configuration;
config.setLocale(locale);
return baseContext.createConfigurationContext(config);
}
return baseContext;
}
catch (ex: Throwable) {
Logger.e(TAG, "Failed to load locale", ex);
return baseContext;
}
}
fun getLocaleSetting(context: Context?): Locale? {
return context?.getSharedPreferences("language", Context.MODE_PRIVATE)
?.getString("language", null)
?.let { Locale(it) };
}
fun setLocaleSetting(context: Context?, locale: String?) {
context?.getSharedPreferences("language", Context.MODE_PRIVATE)
?.edit()
?.putString("language", locale)
?.apply();
}
companion object { companion object {
private val TAG = "StateApp"; private val TAG = "StateApp";
@SuppressLint("StaticFieldLeak") //This is only alive while MainActivity is alive @SuppressLint("StaticFieldLeak") //This is only alive while MainActivity is alive
@@ -1,28 +1,19 @@
package com.futo.platformplayer.states package com.futo.platformplayer.states
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract.EXTRA_INITIAL_URI
import androidx.activity.ComponentActivity
import androidx.core.app.ShareCompat import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.futo.platformplayer.R import com.futo.platformplayer.R
import com.futo.platformplayer.Settings import com.futo.platformplayer.Settings
import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.IWithResultLauncher import com.futo.platformplayer.activities.IWithResultLauncher
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.activities.SettingsActivity import com.futo.platformplayer.activities.SettingsActivity
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.copyTo import com.futo.platformplayer.copyTo
import com.futo.platformplayer.copyToOutputStream import com.futo.platformplayer.encryption.GPasswordEncryptionProvider
import com.futo.platformplayer.encryption.EncryptionProvider import com.futo.platformplayer.encryption.GPasswordEncryptionProviderV0
import com.futo.platformplayer.getInputStream
import com.futo.platformplayer.getNowDiffHours import com.futo.platformplayer.getNowDiffHours
import com.futo.platformplayer.getOutputStream
import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.readBytes import com.futo.platformplayer.readBytes
import com.futo.platformplayer.stores.FragmentedStorage import com.futo.platformplayer.stores.FragmentedStorage
@@ -38,9 +29,8 @@ import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileInputStream
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.InputStream import java.lang.Exception
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
@@ -82,7 +72,7 @@ class StateBackup {
val pbytes = password.toByteArray(); val pbytes = password.toByteArray();
if(pbytes.size < 4 || pbytes.size > 32) if(pbytes.size < 4 || pbytes.size > 32)
throw IllegalStateException("Automatic backup passwords should atleast be 4 character and smaller than 32"); throw IllegalStateException("Automatic backup passwords should atleast be 4 character and smaller than 32");
return password.padStart(32, '9'); return password;
} }
fun hasAutomaticBackup(): Boolean { fun hasAutomaticBackup(): Boolean {
val context = StateApp.instance.contextOrNull ?: return false; val context = StateApp.instance.contextOrNull ?: return false;
@@ -106,8 +96,8 @@ class StateBackup {
val data = export(); val data = export();
val zip = data.asZip(); val zip = data.asZip();
val encryptedZip = EncryptionProvider.instance.encrypt(zip, getAutomaticBackupPassword()); //Prepend some magic bytes to identify everything version 1 and up
val encryptedZip = byteArrayOf(0x11, 0x22, 0x33, 0x44, GPasswordEncryptionProvider.version.toByte()) + GPasswordEncryptionProvider.instance.encrypt(zip, getAutomaticBackupPassword());
if(!Settings.instance.storage.isStorageMainValid(context)) { if(!Settings.instance.storage.isStorageMainValid(context)) {
StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) { StateApp.instance.scopeOrNull?.launch(Dispatchers.Main) {
UIDialogs.toast("Missing permissions for auto-backup, please set the external general directory in settings"); UIDialogs.toast("Missing permissions for auto-backup, please set the external general directory in settings");
@@ -151,8 +141,7 @@ class StateBackup {
throw IllegalStateException("Backup file does not exist"); throw IllegalStateException("Backup file does not exist");
val backupBytesEncrypted = backupFiles.first!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]"); val backupBytesEncrypted = backupFiles.first!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.first?.uri}]");
val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password)); importEncryptedZipBytes(context, scope, backupBytesEncrypted, password);
importZipBytes(context, scope, backupBytes);
Logger.i(TAG, "Finished AutoBackup restore"); Logger.i(TAG, "Finished AutoBackup restore");
} }
catch (exSec: FileNotFoundException) { catch (exSec: FileNotFoundException) {
@@ -179,13 +168,30 @@ class StateBackup {
throw ex; throw ex;
val backupBytesEncrypted = backupFiles.second!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]"); val backupBytesEncrypted = backupFiles.second!!.readBytes(context) ?: throw IllegalStateException("Could not read stream of [${backupFiles.second?.uri}]");
val backupBytes = EncryptionProvider.instance.decrypt(backupBytesEncrypted, getAutomaticBackupPassword(password)); importEncryptedZipBytes(context, scope, backupBytesEncrypted, password);
importZipBytes(context, scope, backupBytes);
Logger.i(TAG, "Finished AutoBackup restore"); Logger.i(TAG, "Finished AutoBackup restore");
} }
} }
} }
private fun importEncryptedZipBytes(context: Context, scope: CoroutineScope, backupBytesEncrypted: ByteArray, password: String) {
val backupBytes: ByteArray;
//Check magic bytes indicating version 1 and up
if (backupBytesEncrypted[0] == 0x11.toByte() && backupBytesEncrypted[1] == 0x22.toByte() && backupBytesEncrypted[2] == 0x33.toByte() && backupBytesEncrypted[3] == 0x44.toByte()) {
val version = backupBytesEncrypted[4].toInt();
if (version != GPasswordEncryptionProvider.version) {
throw Exception("Invalid encryption version");
}
backupBytes = GPasswordEncryptionProvider.instance.decrypt(backupBytesEncrypted.sliceArray(IntRange(5, backupBytesEncrypted.size - 1)), getAutomaticBackupPassword(password))
} else {
//Else its a version 0
backupBytes = GPasswordEncryptionProviderV0(getAutomaticBackupPassword(password).padStart(32, '9')).decrypt(backupBytesEncrypted);
}
importZipBytes(context, scope, backupBytes);
}
fun startExternalBackup() { fun startExternalBackup() {
val data = export(); val data = export();
val now = OffsetDateTime.now(); val now = OffsetDateTime.now();
@@ -400,10 +400,7 @@ class StateDownloads {
_exporting.save(videoExport); _exporting.save(videoExport);
if(notify) { if(notify) {
if(videoSource == null) UIDialogs.toast("Exporting [${shortName}]");
UIDialogs.toast("Exporting [${shortName}]\nIn your music directory under Grayjay");
else
UIDialogs.toast("Exporting [${shortName}]\nIn your movies directory under Grayjay");
StateApp.withContext { ExportingService.getOrCreateService(it) }; StateApp.withContext { ExportingService.getOrCreateService(it) };
onExportsChanged.emit(); onExportsChanged.emit();
} }

Some files were not shown because too many files have changed in this diff Show More