mirror of
https://gitlab.com/vyla-entertainment/TV.git
synced 2026-11-14 20:17:22 -05:00
rewrote the entire app using jetpack compose, and exoplayer/media3 for playback
This commit is contained in:
@@ -1,21 +1,20 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
|
||||
android {
|
||||
namespace "tv.vyla.app"
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "tv.vyla.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
minSdkVersion 24
|
||||
targetSdkVersion 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionName "1.0.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
@@ -23,35 +22,58 @@ android {
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion '1.5.10'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
implementation "androidx.core:core-ktx:1.12.0"
|
||||
implementation "androidx.appcompat:appcompat:1.6.1"
|
||||
implementation "androidx.activity:activity-compose:1.8.2"
|
||||
|
||||
implementation platform("androidx.compose:compose-bom:2024.02.02")
|
||||
implementation "androidx.compose.ui:ui"
|
||||
implementation "androidx.compose.ui:ui-graphics"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview"
|
||||
implementation "androidx.compose.material3:material3"
|
||||
implementation "androidx.compose.material:material-icons-extended"
|
||||
|
||||
implementation "androidx.tv:tv-material:1.0.0"
|
||||
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.7.0"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0"
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-compose:2.7.0"
|
||||
|
||||
implementation "io.coil-kt:coil-compose:2.6.0"
|
||||
|
||||
implementation "androidx.media3:media3-exoplayer:1.3.1"
|
||||
implementation "androidx.media3:media3-exoplayer-hls:1.3.1"
|
||||
implementation "androidx.media3:media3-ui:1.3.1"
|
||||
implementation "androidx.media3:media3-common:1.3.1"
|
||||
implementation "androidx.media3:media3-session:1.3.1"
|
||||
|
||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||
implementation "com.squareup.okhttp3:logging-interceptor:4.12.0"
|
||||
implementation "org.nanohttpd:nanohttpd:2.3.1"
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
testImplementation "junit:junit:4.13.2"
|
||||
androidTestImplementation "androidx.test.ext:junit:1.1.5"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".VylaApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
@@ -21,7 +22,7 @@
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true"
|
||||
@@ -34,19 +35,5 @@
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
|
||||
18
android/app/src/main/assets/vyla-local.js
Normal file
18
android/app/src/main/assets/vyla-local.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,233 +0,0 @@
|
||||
package tv.vyla.app;
|
||||
|
||||
import fi.iki.elonen.NanoHTTPD;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.Arrays;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.ResponseBody;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class LocalProxy extends NanoHTTPD {
|
||||
private static final Pattern STRIP = Pattern.compile("tiktokcdn\\.com|ibyteimg\\.com|seg\\.html|enproxy|letsgocdn\\d+\\.shop", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern SEGSTRIP = Pattern.compile("seg\\.html|enproxy|tiktokcdn|ibyteimg", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static class Code implements Response.IStatus {
|
||||
private final int code;
|
||||
|
||||
Code(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRequestStatus() {
|
||||
return code;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return code + " Status";
|
||||
}
|
||||
}
|
||||
|
||||
public LocalProxy(int port) {
|
||||
super("127.0.0.1", port);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response serve(IHTTPSession session) {
|
||||
Response r;
|
||||
if (session.getMethod() == Method.OPTIONS) r = newFixedLengthResponse(new Code(204), "text/plain", "");
|
||||
else if (!"/api/proxy".equals(session.getUri())) r = text(404, "Not found");
|
||||
else r = proxy(session);
|
||||
r.addHeader("Access-Control-Allow-Origin", "*");
|
||||
r.addHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
|
||||
r.addHeader("Access-Control-Allow-Headers", "*");
|
||||
r.addHeader("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges");
|
||||
return r;
|
||||
}
|
||||
|
||||
private Response text(int code, String msg) {
|
||||
return newFixedLengthResponse(new Code(code), "text/plain; charset=utf-8", msg == null ? "" : msg);
|
||||
}
|
||||
|
||||
private static boolean nap(long ms) {
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
return true;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String enc(String s) {
|
||||
try {
|
||||
return URLEncoder.encode(s, "UTF-8");
|
||||
} catch (Exception e) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
private static String origin(String u) {
|
||||
HttpUrl h = HttpUrl.parse(u);
|
||||
if (h == null) return "";
|
||||
return h.scheme() + "://" + h.host() + (h.port() == HttpUrl.defaultPort(h.scheme()) ? "" : ":" + h.port());
|
||||
}
|
||||
|
||||
private static String abs(String u, String base, String origin) {
|
||||
return u.startsWith("http") ? u : u.startsWith("/") ? origin + u : base + u;
|
||||
}
|
||||
|
||||
private Response proxy(IHTTPSession session) {
|
||||
Map<String, String> p = session.getParms();
|
||||
final String target = p.get("url");
|
||||
if (target == null || target.isEmpty()) return text(400, "Missing url parameter");
|
||||
|
||||
JSONObject ch = new JSONObject();
|
||||
try {
|
||||
if (p.get("headers") != null) ch = new JSONObject(p.get("headers"));
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
ch.remove("Host");
|
||||
ch.remove("host");
|
||||
|
||||
final boolean strip = "1".equals(p.get("strip")) || "1".equals(p.get("tt")) || STRIP.matcher(target).find();
|
||||
|
||||
Request.Builder rb = new Request.Builder();
|
||||
try {
|
||||
rb.url(target);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return text(400, "Bad url");
|
||||
}
|
||||
rb.header("User-Agent", VylaPlugin.UA).header("Accept", "*/*").header("Accept-Language", "en-US,en;q=0.9");
|
||||
Iterator<String> it = ch.keys();
|
||||
while (it.hasNext()) {
|
||||
String k = it.next();
|
||||
if (k.equalsIgnoreCase("accept-encoding")) continue;
|
||||
try {
|
||||
rb.header(k, ch.optString(k));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
}
|
||||
String range = session.getHeaders().get("range");
|
||||
if (range != null) rb.header("Range", range);
|
||||
if (session.getMethod() == Method.HEAD) rb.head();
|
||||
Request req = rb.build();
|
||||
|
||||
okhttp3.Response up = null;
|
||||
IOException last = null;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if (up != null) {
|
||||
up.close();
|
||||
up = null;
|
||||
}
|
||||
try {
|
||||
up = VylaPlugin.CLIENT.newCall(req).execute();
|
||||
int c = up.code();
|
||||
if (up.isSuccessful() || c == 404) break;
|
||||
if (c == 403) {
|
||||
if (i == 2) break;
|
||||
if (!nap(1000)) break;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
last = e;
|
||||
if (i == 2 || !nap(500L * (i + 1))) break;
|
||||
}
|
||||
}
|
||||
if (up == null) return text(502, last != null ? last.getMessage() : "Bad Gateway");
|
||||
|
||||
final okhttp3.Response upf = up;
|
||||
int code = up.code();
|
||||
if (!up.isSuccessful()) {
|
||||
up.close();
|
||||
return text(code, "Upstream error: " + code);
|
||||
}
|
||||
|
||||
try {
|
||||
String ctRaw = up.header("Content-Type", "");
|
||||
String ct = ctRaw.toLowerCase();
|
||||
boolean m3u8 = target.contains(".m3u8") || ct.contains("mpegurl") || ct.contains("m3u8");
|
||||
ResponseBody body = up.body();
|
||||
|
||||
if (target.contains(".srt") || ct.contains("subrip")) {
|
||||
String raw = body == null ? "" : body.string();
|
||||
String vtt = "WEBVTT\n\n" + raw.replaceAll("\r\n|\r", "\n").replaceAll("(\\d{2}:\\d{2}:\\d{2}),(\\d{3})", "$1.$2");
|
||||
up.close();
|
||||
return newFixedLengthResponse(Response.Status.OK, "text/vtt; charset=utf-8", vtt);
|
||||
}
|
||||
|
||||
if (m3u8) {
|
||||
String txt = body == null ? "" : body.string();
|
||||
up.close();
|
||||
String base = target.substring(0, target.lastIndexOf('/') + 1);
|
||||
String origin = origin(target);
|
||||
String hp = ch.length() > 0 ? "&headers=" + enc(ch.toString()) : "";
|
||||
String[] lines = txt.split("\n", -1);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
String raw = lines[i];
|
||||
String t = raw.trim();
|
||||
String out = raw;
|
||||
if (t.startsWith("#EXT-X-KEY") || t.startsWith("#EXT-X-MAP") || t.startsWith("#EXT-X-MEDIA")) {
|
||||
int u = t.indexOf("URI=\"");
|
||||
if (u != -1) {
|
||||
int end = t.indexOf('"', u + 5);
|
||||
if (end != -1) {
|
||||
String uri = t.substring(u + 5, end);
|
||||
out = t.substring(0, u + 5) + "/api/proxy?url=" + enc(abs(uri, base, origin)) + hp + "\"" + t.substring(end + 1);
|
||||
}
|
||||
}
|
||||
} else if (!t.isEmpty() && !t.startsWith("#")) {
|
||||
String a = abs(t, base, origin);
|
||||
String sp = strip || SEGSTRIP.matcher(a).find() ? "&strip=1" : "";
|
||||
out = "/api/proxy?url=" + enc(a) + hp + sp;
|
||||
}
|
||||
sb.append(out);
|
||||
if (i < lines.length - 1) sb.append('\n');
|
||||
}
|
||||
return newFixedLengthResponse(Response.Status.OK, "application/vnd.apple.mpegurl", sb.toString());
|
||||
}
|
||||
|
||||
if (strip) {
|
||||
byte[] b = body == null ? new byte[0] : body.bytes();
|
||||
up.close();
|
||||
if (b.length > 120 && (b[0] == (byte) 0x89 || b[0] == (byte) 0xFF || b[0] == 0x00)) b = Arrays.copyOfRange(b, 120, b.length);
|
||||
return newFixedLengthResponse(Response.Status.OK, "video/MP2T", new ByteArrayInputStream(b), b.length);
|
||||
}
|
||||
|
||||
if (body == null) {
|
||||
up.close();
|
||||
return text(code, "");
|
||||
}
|
||||
long n = body.contentLength();
|
||||
String mime = ctRaw.isEmpty() ? "application/octet-stream" : ctRaw;
|
||||
InputStream in = new FilterInputStream(body.byteStream()) {
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
try {
|
||||
super.close();
|
||||
} finally {
|
||||
upf.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
Response r = n >= 0 ? newFixedLengthResponse(new Code(code), mime, in, n) : newChunkedResponse(new Code(code), mime, in);
|
||||
String cr = up.header("Content-Range");
|
||||
String ar = up.header("Accept-Ranges");
|
||||
if (cr != null) r.addHeader("Content-Range", cr);
|
||||
if (ar != null) r.addHeader("Accept-Ranges", ar);
|
||||
return r;
|
||||
} catch (IOException e) {
|
||||
up.close();
|
||||
return text(502, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package tv.vyla.app;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.WindowManager;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(VylaPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
getBridge().getWebView().getSettings().setMediaPlaybackRequiresUserGesture(false);
|
||||
getBridge().getWebView().requestFocus();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(KeyEvent e) {
|
||||
if (e.getKeyCode() == KeyEvent.KEYCODE_BACK) {
|
||||
if (e.getAction() == KeyEvent.ACTION_UP) {
|
||||
getBridge().getWebView().evaluateJavascript("window.vylaBack?window.vylaBack():false", v -> {
|
||||
if (!"true".equals(v)) finish();
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return super.dispatchKeyEvent(e);
|
||||
}
|
||||
}
|
||||
170
android/app/src/main/java/tv/vyla/app/MainActivity.kt
Normal file
170
android/app/src/main/java/tv/vyla/app/MainActivity.kt
Normal file
@@ -0,0 +1,170 @@
|
||||
package tv.vyla.app
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import tv.vyla.app.data.LibraryManager
|
||||
import tv.vyla.app.model.Episode
|
||||
import tv.vyla.app.model.MediaItem
|
||||
import tv.vyla.app.ui.components.NavDestination
|
||||
import tv.vyla.app.ui.components.Sidebar
|
||||
import tv.vyla.app.ui.screens.*
|
||||
import tv.vyla.app.ui.theme.VylaTvTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
val insetsController = WindowCompat.getInsetsController(window, window.decorView)
|
||||
insetsController.hide(WindowInsetsCompat.Type.systemBars())
|
||||
insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
|
||||
val libraryManager = LibraryManager.getInstance(this)
|
||||
|
||||
setContent {
|
||||
VylaTvTheme {
|
||||
var currentDestination by remember { mutableStateOf(NavDestination.DISCOVERY) }
|
||||
var activeDetailItem by remember { mutableStateOf<MediaItem?>(null) }
|
||||
var playingItem by remember { mutableStateOf<MediaItem?>(null) }
|
||||
var playingEpisode by remember { mutableStateOf<Episode?>(null) }
|
||||
|
||||
val watchLaterItems by libraryManager.watchLater.collectAsState()
|
||||
val favoriteItems by libraryManager.favorites.collectAsState()
|
||||
|
||||
val sidebarFocusRequester = remember { FocusRequester() }
|
||||
val contentFocusRequester = remember { FocusRequester() }
|
||||
|
||||
BackHandler(enabled = playingItem == null && activeDetailItem == null) {
|
||||
if (currentDestination != NavDestination.DISCOVERY) {
|
||||
currentDestination = NavDestination.DISCOVERY
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color(0xFF07070A))) {
|
||||
if (playingItem == null) {
|
||||
Sidebar(
|
||||
currentDestination = currentDestination,
|
||||
hasWatchLater = watchLaterItems.isNotEmpty(),
|
||||
hasFavorites = favoriteItems.isNotEmpty(),
|
||||
onNavigate = { dest ->
|
||||
currentDestination = dest
|
||||
activeDetailItem = null
|
||||
},
|
||||
onRequestExitSidebar = {
|
||||
try {
|
||||
contentFocusRequester.requestFocus()
|
||||
} catch (_: Exception) {}
|
||||
},
|
||||
modifier = Modifier
|
||||
.focusRequester(sidebarFocusRequester)
|
||||
.zIndex(5f)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(contentFocusRequester)
|
||||
) {
|
||||
when (currentDestination) {
|
||||
NavDestination.DISCOVERY,
|
||||
NavDestination.MOVIES,
|
||||
NavDestination.TV_SHOWS -> {
|
||||
BrowseScreen(
|
||||
mode = currentDestination,
|
||||
onOpenDetail = { item -> activeDetailItem = item },
|
||||
onPlay = { item ->
|
||||
playingItem = item
|
||||
playingEpisode = null
|
||||
},
|
||||
onRequestSidebar = {
|
||||
try {
|
||||
sidebarFocusRequester.requestFocus()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
NavDestination.SEARCH -> {
|
||||
SearchScreen(
|
||||
onOpenDetail = { item -> activeDetailItem = item },
|
||||
onRequestSidebar = {
|
||||
try {
|
||||
sidebarFocusRequester.requestFocus()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
NavDestination.WATCH_LATER -> {
|
||||
LibraryScreen(
|
||||
title = "Watch Later",
|
||||
items = watchLaterItems,
|
||||
onOpenDetail = { item -> activeDetailItem = item }
|
||||
)
|
||||
}
|
||||
NavDestination.FAVORITES -> {
|
||||
LibraryScreen(
|
||||
title = "Favorites",
|
||||
items = favoriteItems,
|
||||
onOpenDetail = { item -> activeDetailItem = item }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val detailItem = activeDetailItem
|
||||
if (detailItem != null && playingItem == null) {
|
||||
val isWl = watchLaterItems.any { it.id == detailItem.id && it.mediaType == detailItem.mediaType }
|
||||
val isFav = favoriteItems.any { it.id == detailItem.id && it.mediaType == detailItem.mediaType }
|
||||
|
||||
DetailScreen(
|
||||
item = detailItem,
|
||||
isWatchLater = isWl,
|
||||
isFavorite = isFav,
|
||||
onToggleWatchLater = { libraryManager.toggle("wl", it) },
|
||||
onToggleFavorite = { libraryManager.toggle("fav", it) },
|
||||
onPlay = { itm, ep ->
|
||||
playingItem = itm
|
||||
playingEpisode = ep
|
||||
},
|
||||
onOpenRelated = { related -> activeDetailItem = related },
|
||||
onBack = { activeDetailItem = null },
|
||||
modifier = Modifier.zIndex(15f)
|
||||
)
|
||||
}
|
||||
|
||||
val playerItem = playingItem
|
||||
if (playerItem != null) {
|
||||
PlayerScreen(
|
||||
item = playerItem,
|
||||
initialEpisode = playingEpisode,
|
||||
onBack = {
|
||||
playingItem = null
|
||||
playingEpisode = null
|
||||
},
|
||||
modifier = Modifier.zIndex(20f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
android/app/src/main/java/tv/vyla/app/VylaApp.kt
Normal file
20
android/app/src/main/java/tv/vyla/app/VylaApp.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package tv.vyla.app
|
||||
|
||||
import android.app.Application
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import tv.vyla.app.api.TmdbApi
|
||||
import tv.vyla.app.proxy.LocalProxy
|
||||
import tv.vyla.app.source.VylaResolver
|
||||
|
||||
class VylaApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
LocalProxy.startServer()
|
||||
VylaResolver.getInstance(this)
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
TmdbApi.initGenres()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package tv.vyla.app;
|
||||
|
||||
import android.text.TextUtils;
|
||||
import android.util.Base64;
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Iterator;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import okhttp3.Call;
|
||||
import okhttp3.Callback;
|
||||
import okhttp3.Dispatcher;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
@CapacitorPlugin(name = "VylaNative")
|
||||
public class VylaPlugin extends Plugin {
|
||||
static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||
static final OkHttpClient CLIENT = buildClient();
|
||||
private LocalProxy proxy;
|
||||
|
||||
private static OkHttpClient buildClient() {
|
||||
Dispatcher d = new Dispatcher();
|
||||
d.setMaxRequests(128);
|
||||
d.setMaxRequestsPerHost(32);
|
||||
return new OkHttpClient.Builder()
|
||||
.dispatcher(d)
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void load() {
|
||||
try {
|
||||
proxy = new LocalProxy(38417);
|
||||
proxy.start(5000, true);
|
||||
} catch (IOException e) {
|
||||
proxy = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleOnDestroy() {
|
||||
if (proxy != null) proxy.stop();
|
||||
}
|
||||
|
||||
private static byte[] read(Response r, int max) throws IOException {
|
||||
if (r.body() == null) return new byte[0];
|
||||
InputStream in = r.body().byteStream();
|
||||
ByteArrayOutputStream bo = new ByteArrayOutputStream();
|
||||
byte[] buf = new byte[16384];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
if (max > 0 && bo.size() + n >= max) {
|
||||
bo.write(buf, 0, Math.min(n, max - bo.size()));
|
||||
break;
|
||||
}
|
||||
bo.write(buf, 0, n);
|
||||
}
|
||||
return bo.toByteArray();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void fetch(final PluginCall call) {
|
||||
final String url = call.getString("url");
|
||||
if (url == null) {
|
||||
call.reject("Missing url");
|
||||
return;
|
||||
}
|
||||
final int max = call.getInt("maxBytes", 0);
|
||||
final int timeout = call.getInt("timeout", 30000);
|
||||
final boolean follow = !"manual".equals(call.getString("redirect", "follow"));
|
||||
String method = call.getString("method", "GET").toUpperCase();
|
||||
JSObject hd = call.getObject("headers", new JSObject());
|
||||
Request.Builder rb;
|
||||
try {
|
||||
rb = new Request.Builder().url(url);
|
||||
String ctype = null;
|
||||
Iterator<String> it = hd.keys();
|
||||
while (it.hasNext()) {
|
||||
String k = it.next();
|
||||
if (k.equalsIgnoreCase("accept-encoding")) continue;
|
||||
String v = hd.optString(k, null);
|
||||
if (v == null) continue;
|
||||
rb.header(k, v);
|
||||
if (k.equalsIgnoreCase("content-type")) ctype = v;
|
||||
}
|
||||
if (!hd.has("user-agent")) rb.header("User-Agent", UA);
|
||||
RequestBody rbody = null;
|
||||
if (!method.equals("GET") && !method.equals("HEAD")) {
|
||||
String body = call.getString("body");
|
||||
rbody = RequestBody.create(body == null ? "" : body, MediaType.parse(ctype == null ? "text/plain; charset=utf-8" : ctype));
|
||||
}
|
||||
rb.method(method, rbody);
|
||||
} catch (Exception e) {
|
||||
call.reject(String.valueOf(e.getMessage()));
|
||||
return;
|
||||
}
|
||||
OkHttpClient c = CLIENT.newBuilder()
|
||||
.callTimeout(timeout, TimeUnit.MILLISECONDS)
|
||||
.followRedirects(follow)
|
||||
.followSslRedirects(follow)
|
||||
.build();
|
||||
c.newCall(rb.build()).enqueue(new Callback() {
|
||||
@Override
|
||||
public void onFailure(Call c, IOException e) {
|
||||
call.reject(String.valueOf(e.getMessage()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResponse(Call c, Response r) {
|
||||
try {
|
||||
JSObject out = new JSObject();
|
||||
out.put("status", r.code());
|
||||
out.put("url", r.request().url().toString());
|
||||
JSObject h = new JSObject();
|
||||
for (String n : r.headers().names()) h.put(n.toLowerCase(), TextUtils.join(", ", r.headers(n)));
|
||||
out.put("headers", h);
|
||||
out.put("body", Base64.encodeToString(read(r, max), Base64.NO_WRAP));
|
||||
call.resolve(out);
|
||||
} catch (Exception e) {
|
||||
call.reject(String.valueOf(e.getMessage()));
|
||||
} finally {
|
||||
r.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
71
android/app/src/main/java/tv/vyla/app/api/IntroDbApi.kt
Normal file
71
android/app/src/main/java/tv/vyla/app/api/IntroDbApi.kt
Normal file
@@ -0,0 +1,71 @@
|
||||
package tv.vyla.app.api
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import tv.vyla.app.model.MediaSegment
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object IntroDbApi {
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(8, TimeUnit.SECONDS)
|
||||
.readTimeout(8, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val labels = mapOf(
|
||||
"recap" to "Skip Recap",
|
||||
"intro" to "Skip Intro",
|
||||
"preview" to "Skip Preview",
|
||||
"credits" to "Skip Credits"
|
||||
)
|
||||
|
||||
suspend fun getTimestamps(
|
||||
tmdbId: Int,
|
||||
season: Int? = null,
|
||||
episode: Int? = null,
|
||||
durationSec: Double? = null
|
||||
): List<MediaSegment> = withContext(Dispatchers.IO) {
|
||||
val sb = StringBuilder("https://api.theintrodb.org/v3/media?tmdb_id=$tmdbId")
|
||||
if (season != null) sb.append("&season=$season")
|
||||
if (episode != null) sb.append("&episode=$episode")
|
||||
if (durationSec != null && durationSec > 0) {
|
||||
sb.append("&duration_ms=").append((durationSec * 1000).roundToInt())
|
||||
}
|
||||
|
||||
val req = Request.Builder().url(sb.toString()).build()
|
||||
try {
|
||||
client.newCall(req).execute().use { resp ->
|
||||
if (!resp.isSuccessful) return@withContext emptyList()
|
||||
val body = resp.body?.string() ?: return@withContext emptyList()
|
||||
val json = JSONObject(body)
|
||||
val segments = mutableListOf<MediaSegment>()
|
||||
|
||||
labels.forEach { (type, label) ->
|
||||
val arr = json.optJSONArray(type)
|
||||
if (arr != null) {
|
||||
for (i in 0 until arr.length()) {
|
||||
val seg = arr.getJSONObject(i)
|
||||
val startMs = seg.optDouble("start_ms", 0.0)
|
||||
val endMs = if (seg.has("end_ms") && !seg.isNull("end_ms")) seg.optDouble("end_ms") else null
|
||||
segments.add(
|
||||
MediaSegment(
|
||||
type = type,
|
||||
label = label,
|
||||
start = startMs / 1000.0,
|
||||
end = endMs?.let { it / 1000.0 }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
segments
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
320
android/app/src/main/java/tv/vyla/app/api/TmdbApi.kt
Normal file
320
android/app/src/main/java/tv/vyla/app/api/TmdbApi.kt
Normal file
@@ -0,0 +1,320 @@
|
||||
package tv.vyla.app.api
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import tv.vyla.app.model.*
|
||||
import java.io.IOException
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object TmdbApi {
|
||||
private const val API_KEY = "3f6703f3e2a0b2b40fe4fbb3e1546244"
|
||||
private const val BASE_URL = "https://api.themoviedb.org/3"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val genreMap = ConcurrentHashMap<Int, String>()
|
||||
private val logoCache = ConcurrentHashMap<String, String>()
|
||||
|
||||
suspend fun initGenres() = withContext(Dispatchers.IO) {
|
||||
if (genreMap.isNotEmpty()) return@withContext
|
||||
val m = getGenreList("movie")
|
||||
val t = getGenreList("tv")
|
||||
(m + t).forEach { genreMap[it.id] = it.name }
|
||||
}
|
||||
|
||||
fun getGenreName(id: Int): String? = genreMap[id]
|
||||
|
||||
suspend fun getGenreList(mediaType: String): List<Genre> = withContext(Dispatchers.IO) {
|
||||
val json = fetchJson("/genre/$mediaType/list") ?: return@withContext emptyList()
|
||||
val genres = json.optJSONArray("genres") ?: return@withContext emptyList()
|
||||
val list = mutableListOf<Genre>()
|
||||
for (i in 0 until genres.length()) {
|
||||
val obj = genres.getJSONObject(i)
|
||||
val g = Genre(obj.getInt("id"), obj.getString("name"))
|
||||
list.add(g)
|
||||
genreMap[g.id] = g.name
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
suspend fun getDiscover(mediaType: String, genreId: Int, page: Int): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||
val path = "/discover/$mediaType?with_genres=$genreId&sort_by=popularity.desc&vote_count.gte=100&include_adult=false&page=$page"
|
||||
val json = fetchJson(path) ?: return@withContext emptyList()
|
||||
parseMediaList(json.optJSONArray("results"), mediaType)
|
||||
}
|
||||
|
||||
suspend fun getTrending(mediaType: String): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||
val json = fetchJson("/trending/$mediaType/week") ?: return@withContext emptyList()
|
||||
parseMediaList(json.optJSONArray("results"), mediaType)
|
||||
}
|
||||
|
||||
suspend fun searchMulti(query: String): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||
if (query.isBlank()) return@withContext emptyList()
|
||||
val enc = URLEncoder.encode(query, "UTF-8")
|
||||
val json = fetchJson("/search/multi?query=$enc&include_adult=false") ?: return@withContext emptyList()
|
||||
val results = json.optJSONArray("results") ?: return@withContext emptyList()
|
||||
val list = mutableListOf<MediaItem>()
|
||||
for (i in 0 until results.length()) {
|
||||
val obj = results.getJSONObject(i)
|
||||
val mt = obj.optString("media_type")
|
||||
if (mt == "movie" || mt == "tv") {
|
||||
val item = parseMediaItem(obj, mt)
|
||||
if (!item.posterPath.isNullOrEmpty()) {
|
||||
list.add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
suspend fun getLogo(mediaType: String, id: Int): String? = withContext(Dispatchers.IO) {
|
||||
val cacheKey = "$mediaType$id"
|
||||
logoCache[cacheKey]?.let { return@withContext it }
|
||||
|
||||
val json = fetchJson("/$mediaType/$id/images?include_image_language=en,null")
|
||||
val logos = json?.optJSONArray("logos") ?: return@withContext null
|
||||
var foundPath: String? = null
|
||||
for (i in 0 until logos.length()) {
|
||||
val l = logos.getJSONObject(i)
|
||||
if (l.optString("iso_639_1") == "en") {
|
||||
foundPath = l.optString("file_path")
|
||||
break
|
||||
}
|
||||
}
|
||||
if (foundPath == null && logos.length() > 0) {
|
||||
foundPath = logos.getJSONObject(0).optString("file_path")
|
||||
}
|
||||
if (!foundPath.isNullOrEmpty()) {
|
||||
val fullUrl = "https://image.tmdb.org/t/p/w500$foundPath"
|
||||
logoCache[cacheKey] = fullUrl
|
||||
fullUrl
|
||||
} else null
|
||||
}
|
||||
|
||||
suspend fun getDetail(mediaType: String, id: Int): MediaDetail? = withContext(Dispatchers.IO) {
|
||||
val extra = if (mediaType == "movie") "release_dates" else "content_ratings"
|
||||
val path = "/$mediaType/$id?append_to_response=videos,credits,recommendations,images,$extra&include_image_language=en,null"
|
||||
val json = fetchJson(path) ?: return@withContext null
|
||||
|
||||
val baseItem = parseMediaItem(json, mediaType)
|
||||
val runtime = json.optInt("runtime", 0)
|
||||
val numberOfSeasons = json.optInt("number_of_seasons", 0)
|
||||
|
||||
val genresArr = json.optJSONArray("genres")
|
||||
val genreList = mutableListOf<Genre>()
|
||||
if (genresArr != null) {
|
||||
for (i in 0 until genresArr.length()) {
|
||||
val g = genresArr.getJSONObject(i)
|
||||
genreList.add(Genre(g.getInt("id"), g.getString("name")))
|
||||
}
|
||||
}
|
||||
|
||||
var cert: String? = null
|
||||
if (mediaType == "movie") {
|
||||
val rdResults = json.optJSONObject("release_dates")?.optJSONArray("results")
|
||||
if (rdResults != null) {
|
||||
for (i in 0 until rdResults.length()) {
|
||||
val r = rdResults.getJSONObject(i)
|
||||
if (r.optString("iso_3166_1") == "US") {
|
||||
val dates = r.optJSONArray("release_dates")
|
||||
if (dates != null) {
|
||||
for (j in 0 until dates.length()) {
|
||||
val c = dates.getJSONObject(j).optString("certification")
|
||||
if (c.isNotEmpty()) {
|
||||
cert = c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val crResults = json.optJSONObject("content_ratings")?.optJSONArray("results")
|
||||
if (crResults != null) {
|
||||
for (i in 0 until crResults.length()) {
|
||||
val r = crResults.getJSONObject(i)
|
||||
if (r.optString("iso_3166_1") == "US") {
|
||||
cert = r.optString("rating")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var logoUrl: String? = null
|
||||
val logosArr = json.optJSONObject("images")?.optJSONArray("logos")
|
||||
if (logosArr != null && logosArr.length() > 0) {
|
||||
for (i in 0 until logosArr.length()) {
|
||||
val l = logosArr.getJSONObject(i)
|
||||
if (l.optString("iso_639_1") == "en") {
|
||||
logoUrl = "https://image.tmdb.org/t/p/w500" + l.optString("file_path")
|
||||
break
|
||||
}
|
||||
}
|
||||
if (logoUrl == null) {
|
||||
logoUrl = "https://image.tmdb.org/t/p/w500" + logosArr.getJSONObject(0).optString("file_path")
|
||||
}
|
||||
}
|
||||
|
||||
val videosArr = json.optJSONObject("videos")?.optJSONArray("results")
|
||||
val trailers = mutableListOf<VideoClip>()
|
||||
if (videosArr != null) {
|
||||
for (i in 0 until videosArr.length()) {
|
||||
val v = videosArr.getJSONObject(i)
|
||||
if (v.optString("site") == "YouTube") {
|
||||
val type = v.optString("type")
|
||||
if (type.contains("Trailer") || type.contains("Teaser") || type.contains("Clip")) {
|
||||
trailers.add(
|
||||
VideoClip(
|
||||
id = v.optString("id"),
|
||||
key = v.optString("key"),
|
||||
name = v.optString("name"),
|
||||
site = "YouTube",
|
||||
type = type
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val castArr = json.optJSONObject("credits")?.optJSONArray("cast")
|
||||
val cast = mutableListOf<CastMember>()
|
||||
if (castArr != null) {
|
||||
val maxCast = minOf(castArr.length(), 20)
|
||||
for (i in 0 until maxCast) {
|
||||
val c = castArr.getJSONObject(i)
|
||||
cast.add(
|
||||
CastMember(
|
||||
id = c.getInt("id"),
|
||||
name = c.getString("name"),
|
||||
character = c.optString("character", null),
|
||||
profilePath = c.optString("profile_path", null)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val recsArr = json.optJSONObject("recommendations")?.optJSONArray("results")
|
||||
val recommendations = parseMediaList(recsArr, mediaType)
|
||||
|
||||
val seasons = mutableListOf<Season>()
|
||||
if (mediaType == "tv") {
|
||||
val seasonsArr = json.optJSONArray("seasons")
|
||||
if (seasonsArr != null) {
|
||||
for (i in 0 until seasonsArr.length()) {
|
||||
val s = seasonsArr.getJSONObject(i)
|
||||
val sNum = s.getInt("season_number")
|
||||
val epCount = s.optInt("episode_count", 0)
|
||||
if (sNum > 0 && epCount > 0) {
|
||||
seasons.add(
|
||||
Season(
|
||||
id = s.getInt("id"),
|
||||
seasonNumber = sNum,
|
||||
name = s.optString("name", "Season $sNum"),
|
||||
episodeCount = epCount
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MediaDetail(
|
||||
item = baseItem,
|
||||
runtime = if (runtime > 0) runtime else null,
|
||||
numberOfSeasons = if (numberOfSeasons > 0) numberOfSeasons else null,
|
||||
genres = genreList,
|
||||
certification = cert,
|
||||
logoUrl = logoUrl,
|
||||
trailers = trailers.take(8),
|
||||
cast = cast,
|
||||
recommendations = recommendations,
|
||||
seasons = seasons
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getSeasonEpisodes(tvId: Int, seasonNumber: Int): List<Episode> = withContext(Dispatchers.IO) {
|
||||
val json = fetchJson("/tv/$tvId/season/$seasonNumber") ?: return@withContext emptyList()
|
||||
val eps = json.optJSONArray("episodes") ?: return@withContext emptyList()
|
||||
val list = mutableListOf<Episode>()
|
||||
for (i in 0 until eps.length()) {
|
||||
val ep = eps.getJSONObject(i)
|
||||
list.add(
|
||||
Episode(
|
||||
id = ep.getInt("id"),
|
||||
episodeNumber = ep.getInt("episode_number"),
|
||||
seasonNumber = seasonNumber,
|
||||
name = ep.optString("name", "Episode ${ep.getInt("episode_number")}"),
|
||||
overview = ep.optString("overview", null),
|
||||
stillPath = ep.optString("still_path", null),
|
||||
runtime = ep.optInt("runtime", 0).let { if (it > 0) it else null }
|
||||
)
|
||||
)
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
private fun parseMediaItem(obj: JSONObject, mediaType: String): MediaItem {
|
||||
val genreIds = mutableListOf<Int>()
|
||||
val arr = obj.optJSONArray("genre_ids")
|
||||
if (arr != null) {
|
||||
for (i in 0 until arr.length()) {
|
||||
genreIds.add(arr.getInt(i))
|
||||
}
|
||||
}
|
||||
return MediaItem(
|
||||
id = obj.getInt("id"),
|
||||
mediaType = mediaType,
|
||||
title = obj.optString("title", null),
|
||||
name = obj.optString("name", null),
|
||||
overview = obj.optString("overview", null),
|
||||
posterPath = obj.optString("poster_path", null),
|
||||
backdropPath = obj.optString("backdrop_path", null),
|
||||
releaseDate = obj.optString("release_date", null),
|
||||
firstAirDate = obj.optString("first_air_date", null),
|
||||
voteAverage = obj.optDouble("vote_average", 0.0),
|
||||
genreIds = genreIds
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseMediaList(arr: JSONArray?, mediaType: String): List<MediaItem> {
|
||||
if (arr == null) return emptyList()
|
||||
val list = mutableListOf<MediaItem>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val obj = arr.getJSONObject(i)
|
||||
val poster = obj.optString("poster_path", null)
|
||||
if (!poster.isNullOrEmpty()) {
|
||||
list.add(parseMediaItem(obj, mediaType))
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
private suspend fun fetchJson(path: String): JSONObject? = withContext(Dispatchers.IO) {
|
||||
val separator = if (path.contains("?")) "&" else "?"
|
||||
val url = "$BASE_URL$path${separator}api_key=$API_KEY&language=en-US"
|
||||
val req = Request.Builder().url(url).build()
|
||||
try {
|
||||
client.newCall(req).execute().use { resp ->
|
||||
if (resp.isSuccessful) {
|
||||
val body = resp.body?.string()
|
||||
if (!body.isNullOrEmpty()) JSONObject(body) else null
|
||||
} else null
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
129
android/app/src/main/java/tv/vyla/app/data/LibraryManager.kt
Normal file
129
android/app/src/main/java/tv/vyla/app/data/LibraryManager.kt
Normal file
@@ -0,0 +1,129 @@
|
||||
package tv.vyla.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import tv.vyla.app.model.MediaItem
|
||||
|
||||
class LibraryManager private constructor(context: Context) {
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: LibraryManager? = null
|
||||
|
||||
fun getInstance(context: Context): LibraryManager {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: LibraryManager(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("vyla_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
private val _watchLater = MutableStateFlow<List<MediaItem>>(emptyList())
|
||||
val watchLater: StateFlow<List<MediaItem>> = _watchLater.asStateFlow()
|
||||
|
||||
private val _favorites = MutableStateFlow<List<MediaItem>>(emptyList())
|
||||
val favorites: StateFlow<List<MediaItem>> = _favorites.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
val raw = prefs.getString("vyla.lib", null)
|
||||
if (raw.isNullOrEmpty()) return
|
||||
try {
|
||||
val root = JSONObject(raw)
|
||||
_watchLater.value = parseList(root.optJSONArray("wl"))
|
||||
_favorites.value = parseList(root.optJSONArray("fav"))
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun save() {
|
||||
try {
|
||||
val root = JSONObject()
|
||||
root.put("wl", serializeList(_watchLater.value))
|
||||
root.put("fav", serializeList(_favorites.value))
|
||||
prefs.edit().putString("vyla.lib", root.toString()).apply()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
fun has(type: String, item: MediaItem): Boolean {
|
||||
val list = if (type == "wl") _watchLater.value else _favorites.value
|
||||
return list.any { it.id == item.id && it.mediaType == item.mediaType }
|
||||
}
|
||||
|
||||
fun toggle(type: String, item: MediaItem): Boolean {
|
||||
val isWl = type == "wl"
|
||||
val current = (if (isWl) _watchLater.value else _favorites.value).toMutableList()
|
||||
val index = current.indexOfFirst { it.id == item.id && it.mediaType == item.mediaType }
|
||||
val added: Boolean
|
||||
if (index >= 0) {
|
||||
current.removeAt(index)
|
||||
added = false
|
||||
} else {
|
||||
current.add(0, item)
|
||||
added = true
|
||||
}
|
||||
|
||||
if (isWl) {
|
||||
_watchLater.value = current
|
||||
} else {
|
||||
_favorites.value = current
|
||||
}
|
||||
save()
|
||||
return added
|
||||
}
|
||||
|
||||
private fun parseList(arr: JSONArray?): List<MediaItem> {
|
||||
if (arr == null) return emptyList()
|
||||
val list = mutableListOf<MediaItem>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val obj = arr.getJSONObject(i)
|
||||
val mt = obj.optString("_t").ifEmpty { obj.optString("media_type", "movie") }
|
||||
list.add(
|
||||
MediaItem(
|
||||
id = obj.getInt("id"),
|
||||
mediaType = mt,
|
||||
title = obj.optString("title", null),
|
||||
name = obj.optString("name", null),
|
||||
overview = obj.optString("overview", null),
|
||||
posterPath = obj.optString("poster_path", null),
|
||||
backdropPath = obj.optString("backdrop_path", null),
|
||||
releaseDate = obj.optString("release_date", null),
|
||||
firstAirDate = obj.optString("first_air_date", null),
|
||||
voteAverage = obj.optDouble("vote_average", 0.0)
|
||||
)
|
||||
)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
private fun serializeList(items: List<MediaItem>): JSONArray {
|
||||
val arr = JSONArray()
|
||||
for (item in items) {
|
||||
val obj = JSONObject()
|
||||
obj.put("id", item.id)
|
||||
obj.put("_t", item.mediaType)
|
||||
obj.put("title", item.title)
|
||||
obj.put("name", item.name)
|
||||
obj.put("overview", item.overview)
|
||||
obj.put("poster_path", item.posterPath)
|
||||
obj.put("backdrop_path", item.backdropPath)
|
||||
obj.put("release_date", item.releaseDate)
|
||||
obj.put("first_air_date", item.firstAirDate)
|
||||
obj.put("vote_average", item.voteAverage)
|
||||
arr.put(obj)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
}
|
||||
152
android/app/src/main/java/tv/vyla/app/model/Models.kt
Normal file
152
android/app/src/main/java/tv/vyla/app/model/Models.kt
Normal file
@@ -0,0 +1,152 @@
|
||||
package tv.vyla.app.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class MediaItem(
|
||||
val id: Int,
|
||||
val mediaType: String,
|
||||
val title: String? = null,
|
||||
val name: String? = null,
|
||||
val overview: String? = null,
|
||||
val posterPath: String? = null,
|
||||
val backdropPath: String? = null,
|
||||
val releaseDate: String? = null,
|
||||
val firstAirDate: String? = null,
|
||||
val voteAverage: Double? = null,
|
||||
val genreIds: List<Int> = emptyList()
|
||||
) : Parcelable {
|
||||
val displayTitle: String
|
||||
get() = title ?: name ?: "Untitled"
|
||||
|
||||
val year: String
|
||||
get() {
|
||||
val d = releaseDate ?: firstAirDate ?: ""
|
||||
return if (d.length >= 4) d.substring(0, 4) else ""
|
||||
}
|
||||
|
||||
val formattedRating: String
|
||||
get() = String.format("%.1f", voteAverage ?: 0.0)
|
||||
|
||||
val posterUrl: String?
|
||||
get() = posterPath?.let { "https://image.tmdb.org/t/p/w342$it" }
|
||||
|
||||
val backdropUrl: String?
|
||||
get() = (backdropPath ?: posterPath)?.let { "https://image.tmdb.org/t/p/w1280$it" }
|
||||
|
||||
val ambientBackdropUrl: String?
|
||||
get() = (backdropPath ?: posterPath)?.let { "https://image.tmdb.org/t/p/w780$it" }
|
||||
}
|
||||
|
||||
data class Genre(
|
||||
val id: Int,
|
||||
val name: String
|
||||
)
|
||||
|
||||
data class MediaDetail(
|
||||
val item: MediaItem,
|
||||
val runtime: Int? = null,
|
||||
val numberOfSeasons: Int? = null,
|
||||
val genres: List<Genre> = emptyList(),
|
||||
val certification: String? = null,
|
||||
val logoUrl: String? = null,
|
||||
val trailers: List<VideoClip> = emptyList(),
|
||||
val cast: List<CastMember> = emptyList(),
|
||||
val recommendations: List<MediaItem> = emptyList(),
|
||||
val seasons: List<Season> = emptyList()
|
||||
) {
|
||||
val runtimeOrSeasons: String
|
||||
get() = when {
|
||||
item.mediaType == "tv" -> {
|
||||
val s = numberOfSeasons ?: 1
|
||||
"$s Season${if (s > 1) "s" else ""}"
|
||||
}
|
||||
runtime != null && runtime > 0 -> {
|
||||
"${runtime / 60}h ${runtime % 60}m"
|
||||
}
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Episode(
|
||||
val id: Int,
|
||||
val episodeNumber: Int,
|
||||
val seasonNumber: Int,
|
||||
val name: String? = null,
|
||||
val overview: String? = null,
|
||||
val stillPath: String? = null,
|
||||
val runtime: Int? = null
|
||||
) : Parcelable {
|
||||
val displayTitle: String
|
||||
get() = name ?: "Episode $episodeNumber"
|
||||
|
||||
val stillUrl: String?
|
||||
get() = stillPath?.let { "https://image.tmdb.org/t/p/w300$it" }
|
||||
}
|
||||
|
||||
data class Season(
|
||||
val id: Int,
|
||||
val seasonNumber: Int,
|
||||
val name: String? = null,
|
||||
val episodeCount: Int = 0,
|
||||
val episodes: List<Episode> = emptyList()
|
||||
) {
|
||||
val displayName: String
|
||||
get() = name ?: "Season $seasonNumber"
|
||||
}
|
||||
|
||||
data class CastMember(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val character: String? = null,
|
||||
val profilePath: String? = null
|
||||
) {
|
||||
val profileUrl: String?
|
||||
get() = profilePath?.let { "https://image.tmdb.org/t/p/w185$it" }
|
||||
}
|
||||
|
||||
data class VideoClip(
|
||||
val id: String,
|
||||
val key: String,
|
||||
val name: String,
|
||||
val site: String,
|
||||
val type: String
|
||||
) {
|
||||
val thumbnailUrl: String
|
||||
get() = "https://img.youtube.com/vi/$key/mqdefault.jpg"
|
||||
}
|
||||
|
||||
data class MediaSegment(
|
||||
val type: String,
|
||||
val label: String,
|
||||
val start: Double,
|
||||
val end: Double?
|
||||
)
|
||||
|
||||
data class VylaSource(
|
||||
val key: String,
|
||||
val label: String
|
||||
)
|
||||
|
||||
data class StreamUrl(
|
||||
val url: String,
|
||||
val quality: String = "Auto",
|
||||
val type: String = "hls",
|
||||
val server: String? = null
|
||||
)
|
||||
|
||||
data class ResolvedStream(
|
||||
val provider: String,
|
||||
val label: String,
|
||||
val headers: Map<String, String>,
|
||||
val primaryUrl: String,
|
||||
val allUrls: List<StreamUrl>
|
||||
)
|
||||
|
||||
data class SubtitleTrack(
|
||||
val file: String,
|
||||
val label: String,
|
||||
val source: String? = "mirror"
|
||||
)
|
||||
281
android/app/src/main/java/tv/vyla/app/proxy/LocalProxy.kt
Normal file
281
android/app/src/main/java/tv/vyla/app/proxy/LocalProxy.kt
Normal file
@@ -0,0 +1,281 @@
|
||||
package tv.vyla.app.proxy
|
||||
|
||||
import fi.iki.elonen.NanoHTTPD
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.FilterInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class LocalProxy(port: Int = PORT) : NanoHTTPD("127.0.0.1", port) {
|
||||
|
||||
companion object {
|
||||
const val PORT = 38417
|
||||
const val UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
|
||||
private val STRIP = Pattern.compile("tiktokcdn\\.com|ibyteimg\\.com|seg\\.html|enproxy|letsgocdn\\d+\\.shop", Pattern.CASE_INSENSITIVE)
|
||||
private val SEGSTRIP = Pattern.compile("seg\\.html|enproxy|tiktokcdn|ibyteimg", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
val CLIENT: OkHttpClient = OkHttpClient.Builder()
|
||||
.dispatcher(Dispatcher().apply {
|
||||
maxRequests = 128
|
||||
maxRequestsPerHost = 32
|
||||
})
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
@Volatile
|
||||
private var instance: LocalProxy? = null
|
||||
|
||||
@Synchronized
|
||||
fun startServer() {
|
||||
if (instance == null) {
|
||||
try {
|
||||
val server = LocalProxy(PORT)
|
||||
server.start(5000, true)
|
||||
instance = server
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stopServer() {
|
||||
instance?.stop()
|
||||
instance = null
|
||||
}
|
||||
|
||||
fun buildProxyUrl(rawUrl: String, headers: Map<String, String>? = null, strip: Boolean = false): String {
|
||||
val encodedUrl = URLEncoder.encode(rawUrl, "UTF-8")
|
||||
val sb = java.lang.StringBuilder("http://127.0.0.1:$PORT/api/proxy?url=$encodedUrl")
|
||||
if (!headers.isNullOrEmpty()) {
|
||||
val json = JSONObject(headers).toString()
|
||||
sb.append("&headers=").append(URLEncoder.encode(json, "UTF-8"))
|
||||
}
|
||||
if (strip) {
|
||||
sb.append("&strip=1")
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private class Code(private val code: Int) : Response.IStatus {
|
||||
override fun getRequestStatus(): Int = code
|
||||
override fun getDescription(): String = "$code Status"
|
||||
}
|
||||
|
||||
override fun serve(session: IHTTPSession): Response {
|
||||
val r: Response = when {
|
||||
session.method == Method.OPTIONS -> newFixedLengthResponse(Code(204), "text/plain", "")
|
||||
session.uri != "/api/proxy" -> text(404, "Not found")
|
||||
else -> proxy(session)
|
||||
}
|
||||
r.addHeader("Access-Control-Allow-Origin", "*")
|
||||
r.addHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
||||
r.addHeader("Access-Control-Allow-Headers", "*")
|
||||
r.addHeader("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
|
||||
return r
|
||||
}
|
||||
|
||||
private fun text(code: Int, msg: String?): Response {
|
||||
return newFixedLengthResponse(Code(code), "text/plain; charset=utf-8", msg ?: "")
|
||||
}
|
||||
|
||||
private fun enc(s: String): String {
|
||||
return try {
|
||||
URLEncoder.encode(s, "UTF-8")
|
||||
} catch (_: Exception) {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
private fun origin(u: String): String {
|
||||
val h = u.toHttpUrlOrNull() ?: return ""
|
||||
val portStr = if (h.port == okhttp3.HttpUrl.defaultPort(h.scheme)) "" else ":${h.port}"
|
||||
return "${h.scheme}://${h.host}$portStr"
|
||||
}
|
||||
|
||||
private fun abs(u: String, base: String, origin: String): String {
|
||||
return when {
|
||||
u.startsWith("http") -> u
|
||||
u.startsWith("/") -> origin + u
|
||||
else -> base + u
|
||||
}
|
||||
}
|
||||
|
||||
private fun nap(ms: Long): Boolean {
|
||||
return try {
|
||||
Thread.sleep(ms)
|
||||
true
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun proxy(session: IHTTPSession): Response {
|
||||
val params = session.parms
|
||||
val target = params["url"] ?: return text(400, "Missing url parameter")
|
||||
if (target.isEmpty()) return text(400, "Missing url parameter")
|
||||
|
||||
var ch = JSONObject()
|
||||
try {
|
||||
val hd = params["headers"]
|
||||
if (!hd.isNullOrEmpty()) ch = JSONObject(hd)
|
||||
} catch (_: Exception) {}
|
||||
|
||||
ch.remove("Host")
|
||||
ch.remove("host")
|
||||
|
||||
val strip = "1" == params["strip"] || "1" == params["tt"] || STRIP.matcher(target).find()
|
||||
|
||||
val rb = Request.Builder()
|
||||
try {
|
||||
rb.url(target)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
return text(400, "Bad url")
|
||||
}
|
||||
|
||||
rb.header("User-Agent", UA)
|
||||
.header("Accept", "*/*")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
|
||||
val it = ch.keys()
|
||||
while (it.hasNext()) {
|
||||
val k = it.next()
|
||||
if (k.equals("accept-encoding", ignoreCase = true)) continue
|
||||
try {
|
||||
rb.header(k, ch.optString(k))
|
||||
} catch (_: IllegalArgumentException) {}
|
||||
}
|
||||
|
||||
val range = session.headers["range"]
|
||||
if (range != null) rb.header("Range", range)
|
||||
if (session.method == Method.HEAD) rb.head()
|
||||
|
||||
val req = rb.build()
|
||||
var up: okhttp3.Response? = null
|
||||
var last: IOException? = null
|
||||
|
||||
for (i in 0 until 3) {
|
||||
up?.close()
|
||||
up = null
|
||||
try {
|
||||
val call = CLIENT.newCall(req)
|
||||
val resp = call.execute()
|
||||
val c = resp.code
|
||||
up = resp
|
||||
if (resp.isSuccessful || c == 404) break
|
||||
if (c == 403) {
|
||||
if (i == 2 || !nap(1000)) break
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
last = e
|
||||
if (i == 2 || !nap(500L * (i + 1))) break
|
||||
}
|
||||
}
|
||||
|
||||
if (up == null) return text(502, last?.message ?: "Bad Gateway")
|
||||
|
||||
val upf = up
|
||||
val code = upf.code
|
||||
if (!upf.isSuccessful && code != 206) {
|
||||
upf.close()
|
||||
return text(code, "Upstream error: $code")
|
||||
}
|
||||
|
||||
return try {
|
||||
val ctRaw = upf.header("Content-Type", "") ?: ""
|
||||
val ct = ctRaw.lowercase()
|
||||
val m3u8 = target.contains(".m3u8") || ct.contains("mpegurl") || ct.contains("m3u8")
|
||||
val body = upf.body
|
||||
|
||||
if (target.contains(".srt") || ct.contains("subrip")) {
|
||||
val raw = body?.string() ?: ""
|
||||
val vtt = "WEBVTT\n\n" + raw.replace("\r\n".toRegex(), "\n")
|
||||
.replace("\r".toRegex(), "\n")
|
||||
.replace("(\\d{2}:\\d{2}:\\d{2}),(\\d{3})".toRegex(), "$1.$2")
|
||||
upf.close()
|
||||
return newFixedLengthResponse(Response.Status.OK, "text/vtt; charset=utf-8", vtt)
|
||||
}
|
||||
|
||||
if (m3u8) {
|
||||
val txt = body?.string() ?: ""
|
||||
upf.close()
|
||||
val base = target.substring(0, target.lastIndexOf('/') + 1)
|
||||
val org = origin(target)
|
||||
val hp = if (ch.length() > 0) "&headers=" + enc(ch.toString()) else ""
|
||||
val lines = txt.split("\n".toRegex()).dropLastWhile { false }.toTypedArray()
|
||||
val sb = java.lang.StringBuilder()
|
||||
|
||||
for (i in lines.indices) {
|
||||
val raw = lines[i]
|
||||
val t = raw.trim()
|
||||
var out = raw
|
||||
if (t.startsWith("#EXT-X-KEY") || t.startsWith("#EXT-X-MAP") || t.startsWith("#EXT-X-MEDIA")) {
|
||||
val u = t.indexOf("URI=\"")
|
||||
if (u != -1) {
|
||||
val end = t.indexOf('"', u + 5)
|
||||
if (end != -1) {
|
||||
val uri = t.substring(u + 5, end)
|
||||
out = t.substring(0, u + 5) + "/api/proxy?url=" + enc(abs(uri, base, org)) + hp + "\"" + t.substring(end + 1)
|
||||
}
|
||||
}
|
||||
} else if (t.isNotEmpty() && !t.startsWith("#")) {
|
||||
val a = abs(t, base, org)
|
||||
val sp = if (strip || SEGSTRIP.matcher(a).find()) "&strip=1" else ""
|
||||
out = "/api/proxy?url=" + enc(a) + hp + sp
|
||||
}
|
||||
sb.append(out)
|
||||
if (i < lines.size - 1) sb.append('\n')
|
||||
}
|
||||
return newFixedLengthResponse(Response.Status.OK, "application/vnd.apple.mpegurl", sb.toString())
|
||||
}
|
||||
|
||||
if (strip) {
|
||||
val b = body?.bytes() ?: ByteArray(0)
|
||||
upf.close()
|
||||
val finalBytes = if (b.size > 120 && (b[0] == 0x89.toByte() || b[0] == 0xFF.toByte() || b[0] == 0x00.toByte())) {
|
||||
b.copyOfRange(120, b.size)
|
||||
} else b
|
||||
return newFixedLengthResponse(Response.Status.OK, "video/MP2T", ByteArrayInputStream(finalBytes), finalBytes.size.toLong())
|
||||
}
|
||||
|
||||
if (body == null) {
|
||||
upf.close()
|
||||
return text(code, "")
|
||||
}
|
||||
|
||||
val n = body.contentLength()
|
||||
val mime = if (ctRaw.isEmpty()) "application/octet-stream" else ctRaw
|
||||
val inputStream: InputStream = object : FilterInputStream(body.byteStream()) {
|
||||
override fun close() {
|
||||
try {
|
||||
super.close()
|
||||
} finally {
|
||||
upf.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val resp = if (n >= 0) newFixedLengthResponse(Code(code), mime, inputStream, n) else newChunkedResponse(Code(code), mime, inputStream)
|
||||
val cr = upf.header("Content-Range")
|
||||
val ar = upf.header("Accept-Ranges")
|
||||
if (cr != null) resp.addHeader("Content-Range", cr)
|
||||
if (ar != null) resp.addHeader("Accept-Ranges", ar)
|
||||
resp
|
||||
} catch (e: IOException) {
|
||||
upf.close()
|
||||
text(502, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
505
android/app/src/main/java/tv/vyla/app/source/VylaResolver.kt
Normal file
505
android/app/src/main/java/tv/vyla/app/source/VylaResolver.kt
Normal file
@@ -0,0 +1,505 @@
|
||||
package tv.vyla.app.source
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import tv.vyla.app.model.ResolvedStream
|
||||
import tv.vyla.app.model.StreamUrl
|
||||
import tv.vyla.app.model.SubtitleTrack
|
||||
import tv.vyla.app.model.VylaSource
|
||||
import tv.vyla.app.proxy.LocalProxy
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class VylaResolver private constructor(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TMDB_KEY = "3f6703f3e2a0b2b40fe4fbb3e1546244"
|
||||
|
||||
@Volatile
|
||||
private var instance: VylaResolver? = null
|
||||
|
||||
fun getInstance(context: Context): VylaResolver {
|
||||
return instance ?: synchronized(this) {
|
||||
instance ?: VylaResolver(context.applicationContext).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
private var webView: WebView? = null
|
||||
private val isReadyDeferred = CompletableDeferred<List<VylaSource>>()
|
||||
|
||||
private val pendingStreams = ConcurrentHashMap<String, CompletableDeferred<ResolvedStream?>>()
|
||||
private val pendingSubtitles = ConcurrentHashMap<String, CompletableDeferred<List<SubtitleTrack>>>()
|
||||
private val callCounter = AtomicInteger(0)
|
||||
|
||||
private var cachedSources: List<VylaSource> = emptyList()
|
||||
|
||||
init {
|
||||
mainHandler.post { initWebView() }
|
||||
}
|
||||
|
||||
private fun initWebView() {
|
||||
val wv = WebView(context)
|
||||
wv.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
allowFileAccess = true
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
}
|
||||
|
||||
wv.addJavascriptInterface(Bridge(), "AndroidBridge")
|
||||
|
||||
val bootstrapHtml = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script>
|
||||
window.pendingFetches = {};
|
||||
|
||||
window.fetch = function(url, init) {
|
||||
init = init || {};
|
||||
const reqId = 'f_' + Math.random().toString(36).slice(2) + Date.now();
|
||||
const isReq = typeof url !== 'string';
|
||||
const finalUrl = (isReq ? url.url : url) || '';
|
||||
const method = (init.method || (isReq ? url.method : 'GET') || 'GET').toUpperCase();
|
||||
const headers = {};
|
||||
if (init.headers) {
|
||||
if (typeof init.headers.forEach === 'function') {
|
||||
init.headers.forEach((v, k) => headers[k] = v);
|
||||
} else if (Array.isArray(init.headers)) {
|
||||
init.headers.forEach(([k, v]) => headers[k] = v);
|
||||
} else {
|
||||
Object.assign(headers, init.headers);
|
||||
}
|
||||
}
|
||||
const body = init.body != null ? String(init.body) : '';
|
||||
const timeout = init.timeout || 30000;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
window.pendingFetches[reqId] = { resolve, reject };
|
||||
AndroidBridge.nativeFetch(reqId, finalUrl, method, JSON.stringify(headers), body, timeout);
|
||||
});
|
||||
};
|
||||
|
||||
window.__onFetchSuccess = function(reqId, status, headersJson, base64Body) {
|
||||
const p = window.pendingFetches[reqId];
|
||||
if (!p) return;
|
||||
delete window.pendingFetches[reqId];
|
||||
|
||||
const h = JSON.parse(headersJson || '{}');
|
||||
const binary = atob(base64Body || '');
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
const text = new TextDecoder('utf-8').decode(bytes);
|
||||
|
||||
p.resolve({
|
||||
ok: status >= 200 && status < 300,
|
||||
status: status,
|
||||
statusText: '',
|
||||
headers: {
|
||||
get: k => h[String(k).toLowerCase()] || null,
|
||||
has: k => String(k).toLowerCase() in h
|
||||
},
|
||||
text: async () => text,
|
||||
json: async () => JSON.parse(text),
|
||||
arrayBuffer: async () => bytes.buffer
|
||||
});
|
||||
};
|
||||
|
||||
window.__onFetchError = function(reqId, err) {
|
||||
const p = window.pendingFetches[reqId];
|
||||
if (!p) return;
|
||||
delete window.pendingFetches[reqId];
|
||||
p.reject(new Error(err || 'Fetch failed'));
|
||||
};
|
||||
|
||||
window.__initVyla = function(apiKey) {
|
||||
if (window.VylaLocal) {
|
||||
window.VylaLocal.init(apiKey);
|
||||
const sources = window.VylaLocal.sources().map(s => ({ key: s.key, label: s.label }));
|
||||
AndroidBridge.onInitialized(JSON.stringify(sources));
|
||||
}
|
||||
};
|
||||
|
||||
window.__stream = async function(callId, key, id, s, e) {
|
||||
try {
|
||||
const stream = await window.VylaLocal.stream(key, String(id), s || undefined, e || undefined);
|
||||
AndroidBridge.onStreamSuccess(callId, key, JSON.stringify(stream || null));
|
||||
} catch (err) {
|
||||
AndroidBridge.onStreamError(callId, String(err && err.message ? err.message : err));
|
||||
}
|
||||
};
|
||||
|
||||
window.__subtitles = async function(callId, id, s, e) {
|
||||
try {
|
||||
const subs = await window.VylaLocal.subtitles(String(id), s || undefined, e || undefined);
|
||||
AndroidBridge.onSubtitlesSuccess(callId, JSON.stringify(subs || []));
|
||||
} catch (err) {
|
||||
AndroidBridge.onSubtitlesSuccess(callId, '[]');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script src="file:///android_asset/vyla-local.js"></script>
|
||||
</head>
|
||||
<body onload="__initVyla('$TMDB_KEY')">
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
|
||||
wv.loadDataWithBaseURL("https://localhost", bootstrapHtml, "text/html", "UTF-8", null)
|
||||
webView = wv
|
||||
}
|
||||
|
||||
private inner class Bridge {
|
||||
@JavascriptInterface
|
||||
fun nativeFetch(reqId: String, url: String, method: String, headersJson: String, body: String, timeoutMs: Int) {
|
||||
scope.launch {
|
||||
try {
|
||||
val rb = Request.Builder().url(url)
|
||||
var ctype: String? = null
|
||||
val headersObj = JSONObject(headersJson)
|
||||
val it = headersObj.keys()
|
||||
while (it.hasNext()) {
|
||||
val k = it.next()
|
||||
if (k.equals("accept-encoding", ignoreCase = true)) continue
|
||||
val v = headersObj.optString(k, null) ?: continue
|
||||
rb.header(k, v)
|
||||
if (k.equals("content-type", ignoreCase = true)) ctype = v
|
||||
}
|
||||
if (!headersObj.has("user-agent") && !headersObj.has("User-Agent")) {
|
||||
rb.header("User-Agent", LocalProxy.UA)
|
||||
}
|
||||
|
||||
if (method != "GET" && method != "HEAD") {
|
||||
val mediaType = (ctype ?: "text/plain; charset=utf-8").toMediaTypeOrNull()
|
||||
rb.method(method, body.toRequestBody(mediaType))
|
||||
} else {
|
||||
rb.method(method, null)
|
||||
}
|
||||
|
||||
val customClient = LocalProxy.CLIENT.newBuilder()
|
||||
.callTimeout(timeoutMs.toLong(), TimeUnit.MILLISECONDS)
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
|
||||
customClient.newCall(rb.build()).execute().use { resp ->
|
||||
val code = resp.code
|
||||
val bytes = resp.body?.bytes() ?: ByteArray(0)
|
||||
val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
val outHeaders = JSONObject()
|
||||
for (name in resp.headers.names()) {
|
||||
outHeaders.put(name.lowercase(), resp.headers(name).joinToString(", "))
|
||||
}
|
||||
|
||||
val safeHeaders = JSONObject.quote(outHeaders.toString())
|
||||
runOnJs("window.__onFetchSuccess('$reqId', $code, $safeHeaders, '$b64')")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val errMsg = JSONObject.quote(e.message ?: "Network error")
|
||||
runOnJs("window.__onFetchError('$reqId', $errMsg)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun onInitialized(sourcesJson: String) {
|
||||
val list = mutableListOf<VylaSource>()
|
||||
try {
|
||||
val arr = JSONArray(sourcesJson)
|
||||
for (i in 0 until arr.length()) {
|
||||
val obj = arr.getJSONObject(i)
|
||||
list.add(VylaSource(obj.getString("key"), obj.getString("label")))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
cachedSources = list
|
||||
isReadyDeferred.complete(list)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun onStreamSuccess(callId: String, providerKey: String, streamJson: String) {
|
||||
val deferred = pendingStreams.remove(callId) ?: return
|
||||
try {
|
||||
if (streamJson.isEmpty() || streamJson == "null") {
|
||||
deferred.complete(null)
|
||||
return
|
||||
}
|
||||
val obj = JSONObject(streamJson)
|
||||
val resolved = parseStreamObject(obj, providerKey)
|
||||
deferred.complete(resolved)
|
||||
} catch (e: Exception) {
|
||||
deferred.complete(null)
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun onStreamError(callId: String, error: String) {
|
||||
val deferred = pendingStreams.remove(callId)
|
||||
deferred?.complete(null)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun onSubtitlesSuccess(callId: String, subsJson: String) {
|
||||
val deferred = pendingSubtitles.remove(callId) ?: return
|
||||
try {
|
||||
val arr = JSONArray(subsJson)
|
||||
val list = mutableListOf<SubtitleTrack>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val obj = arr.getJSONObject(i)
|
||||
list.add(
|
||||
SubtitleTrack(
|
||||
file = obj.getString("file"),
|
||||
label = obj.getString("label"),
|
||||
source = obj.optString("source", "mirror")
|
||||
)
|
||||
)
|
||||
}
|
||||
deferred.complete(list)
|
||||
} catch (e: Exception) {
|
||||
deferred.complete(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseStreamObject(obj: JSONObject, providerKey: String = "Vyla"): ResolvedStream? {
|
||||
val streamData = if (obj.has("stream")) obj.getJSONObject("stream") else obj
|
||||
val headers = mutableMapOf<String, String>()
|
||||
val hdObj = streamData.optJSONObject("headers")
|
||||
if (hdObj != null) {
|
||||
val it = hdObj.keys()
|
||||
while (it.hasNext()) {
|
||||
val k = it.next()
|
||||
headers[k] = hdObj.getString(k)
|
||||
}
|
||||
}
|
||||
|
||||
val allUrls = mutableListOf<StreamUrl>()
|
||||
val arr = streamData.optJSONArray("allUrls")
|
||||
if (arr != null && arr.length() > 0) {
|
||||
for (i in 0 until arr.length()) {
|
||||
val uObj = arr.getJSONObject(i)
|
||||
val u = uObj.optString("url")
|
||||
if (u.isNotEmpty()) {
|
||||
allUrls.add(
|
||||
StreamUrl(
|
||||
url = u,
|
||||
quality = uObj.optString("quality", "Auto"),
|
||||
type = uObj.optString("type", if (u.contains(".m3u8")) "hls" else "mp4"),
|
||||
server = uObj.optString("server", null)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val u = streamData.optString("url")
|
||||
if (u.isNotEmpty()) {
|
||||
allUrls.add(
|
||||
StreamUrl(
|
||||
url = u,
|
||||
quality = streamData.optString("quality", "Auto"),
|
||||
type = if (u.contains(".m3u8")) "hls" else "mp4"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (allUrls.isEmpty()) return null
|
||||
|
||||
val rawProvider = if (obj.has("provider") && obj.getString("provider").isNotEmpty() && obj.getString("provider") != "Vyla") {
|
||||
obj.getString("provider")
|
||||
} else {
|
||||
providerKey
|
||||
}
|
||||
val label = cachedSources.find { it.key.equals(rawProvider, ignoreCase = true) }?.label ?: rawProvider
|
||||
|
||||
return ResolvedStream(
|
||||
provider = rawProvider,
|
||||
label = label,
|
||||
headers = headers,
|
||||
primaryUrl = allUrls[0].url,
|
||||
allUrls = allUrls
|
||||
)
|
||||
}
|
||||
|
||||
private fun runOnJs(js: String) {
|
||||
mainHandler.post {
|
||||
webView?.evaluateJavascript(js, null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSources(): List<VylaSource> {
|
||||
return if (cachedSources.isNotEmpty()) cachedSources else isReadyDeferred.await()
|
||||
}
|
||||
|
||||
suspend fun resolveStream(
|
||||
provider: String,
|
||||
tmdbId: Int,
|
||||
season: Int? = null,
|
||||
episode: Int? = null
|
||||
): ResolvedStream? = withContext(Dispatchers.IO) {
|
||||
getSources()
|
||||
val callId = "s_${callCounter.incrementAndGet()}"
|
||||
val deferred = CompletableDeferred<ResolvedStream?>()
|
||||
pendingStreams[callId] = deferred
|
||||
|
||||
val sArg = season?.toString() ?: "null"
|
||||
val eArg = episode?.toString() ?: "null"
|
||||
|
||||
runOnJs("window.__stream('$callId', '$provider', $tmdbId, $sArg, $eArg)")
|
||||
|
||||
try {
|
||||
withTimeout(30000) {
|
||||
deferred.await()
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
pendingStreams.remove(callId)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSubtitles(
|
||||
tmdbId: Int,
|
||||
season: Int? = null,
|
||||
episode: Int? = null
|
||||
): List<SubtitleTrack> = withContext(Dispatchers.IO) {
|
||||
getSources()
|
||||
val callId = "sub_${callCounter.incrementAndGet()}"
|
||||
val deferred = CompletableDeferred<List<SubtitleTrack>>()
|
||||
pendingSubtitles[callId] = deferred
|
||||
|
||||
val sArg = season?.toString() ?: "null"
|
||||
val eArg = episode?.toString() ?: "null"
|
||||
|
||||
runOnJs("window.__subtitles('$callId', $tmdbId, $sArg, $eArg)")
|
||||
|
||||
try {
|
||||
withTimeout(15000) {
|
||||
deferred.await()
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
pendingSubtitles.remove(callId)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isStreamPlayable(url: String, headers: Map<String, String> = emptyMap(), depth: Int = 0): Boolean = withContext(Dispatchers.IO) {
|
||||
if (depth > 4) return@withContext false
|
||||
try {
|
||||
val rb = Request.Builder().url(url)
|
||||
.header("User-Agent", LocalProxy.UA)
|
||||
.header("Accept", "*/*")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
.header("Range", "bytes=0-1024")
|
||||
|
||||
headers.forEach { (k, v) ->
|
||||
if (!k.equals("accept-encoding", ignoreCase = true)) {
|
||||
rb.header(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
val client = LocalProxy.CLIENT.newBuilder()
|
||||
.callTimeout(6, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
client.newCall(rb.build()).execute().use { resp ->
|
||||
val code = resp.code
|
||||
if (code != 200 && code != 206 && code != 304) return@withContext false
|
||||
|
||||
val ct = (resp.header("Content-Type") ?: "").lowercase()
|
||||
val isM3U8 = url.contains(".m3u8") || ct.contains("mpegurl") || ct.contains("m3u8")
|
||||
if (!isM3U8) return@withContext true
|
||||
|
||||
val text = resp.body?.string() ?: return@withContext false
|
||||
val lines = text.split("\n".toRegex()).map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
if (lines.isEmpty()) return@withContext false
|
||||
|
||||
val firstLine = lines[0]
|
||||
val origin = run {
|
||||
val h = url.toHttpUrlOrNull()
|
||||
if (h == null) return@run ""
|
||||
val p = if (h.port == okhttp3.HttpUrl.defaultPort(h.scheme)) "" else ":${h.port}"
|
||||
"${h.scheme}://${h.host}$p"
|
||||
}
|
||||
val base = url.substring(0, url.lastIndexOf('/') + 1)
|
||||
val target = when {
|
||||
firstLine.startsWith("http") -> firstLine
|
||||
firstLine.startsWith("/") -> origin + firstLine
|
||||
else -> base + firstLine
|
||||
}
|
||||
|
||||
if (target.contains(".m3u8")) {
|
||||
return@withContext isStreamPlayable(target, headers, depth + 1)
|
||||
}
|
||||
|
||||
val segRb = Request.Builder().url(target)
|
||||
.header("User-Agent", LocalProxy.UA)
|
||||
.header("Accept", "*/*")
|
||||
.header("Range", "bytes=0-1024")
|
||||
headers.forEach { (k, v) -> rb.header(k, v) }
|
||||
|
||||
client.newCall(segRb.build()).execute().use { segResp ->
|
||||
segResp.code in listOf(200, 206, 304)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun startConcurrentDiscovery(
|
||||
tmdbId: Int,
|
||||
season: Int? = null,
|
||||
episode: Int? = null,
|
||||
onFirstPlayable: (ResolvedStream) -> Unit,
|
||||
onStreamResolved: (ResolvedStream) -> Unit
|
||||
): Job {
|
||||
return scope.launch {
|
||||
val sources = getSources()
|
||||
var firstPlayableFound = false
|
||||
|
||||
sources.map { src ->
|
||||
launch {
|
||||
try {
|
||||
val stream = resolveStream(src.key, tmdbId, season, episode)
|
||||
if (stream != null && stream.allUrls.isNotEmpty()) {
|
||||
val playableUrls = stream.allUrls.filter { isStreamPlayable(it.url, stream.headers) }
|
||||
if (playableUrls.isNotEmpty()) {
|
||||
val verifiedStream = stream.copy(
|
||||
primaryUrl = playableUrls[0].url,
|
||||
allUrls = playableUrls
|
||||
)
|
||||
onStreamResolved(verifiedStream)
|
||||
synchronized(this@VylaResolver) {
|
||||
if (!firstPlayableFound) {
|
||||
firstPlayableFound = true
|
||||
onFirstPlayable(verifiedStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package tv.vyla.app.ui.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import tv.vyla.app.model.MediaItem
|
||||
import tv.vyla.app.ui.theme.VylaCardBackground
|
||||
import tv.vyla.app.ui.theme.VylaTextPrimary
|
||||
|
||||
@Composable
|
||||
fun MediaCard(
|
||||
item: MediaItem,
|
||||
modifier: Modifier = Modifier,
|
||||
width: Int = 140,
|
||||
onSelect: (MediaItem) -> Unit,
|
||||
onFocused: ((MediaItem) -> Unit)? = null
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1.07f else 1.0f,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "card_scale"
|
||||
)
|
||||
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(width.dp)
|
||||
.aspectRatio(2f / 3f)
|
||||
.scale(scale)
|
||||
.clip(shape)
|
||||
.background(VylaCardBackground)
|
||||
.then(
|
||||
if (isFocused) {
|
||||
Modifier.border(2.dp, Color.White, shape)
|
||||
} else Modifier
|
||||
)
|
||||
.onFocusChanged { state ->
|
||||
isFocused = state.isFocused
|
||||
if (state.isFocused) {
|
||||
onFocused?.invoke(item)
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
onSelect(item)
|
||||
}
|
||||
) {
|
||||
val poster = item.posterUrl
|
||||
if (!poster.isNullOrEmpty()) {
|
||||
AsyncImage(
|
||||
model = poster,
|
||||
contentDescription = item.displayTitle,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = item.displayTitle,
|
||||
color = VylaTextPrimary,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
235
android/app/src/main/java/tv/vyla/app/ui/components/Sidebar.kt
Normal file
235
android/app/src/main/java/tv/vyla/app/ui/components/Sidebar.kt
Normal file
@@ -0,0 +1,235 @@
|
||||
package tv.vyla.app.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import tv.vyla.app.R
|
||||
import tv.vyla.app.ui.theme.VylaTextMuted
|
||||
import tv.vyla.app.ui.theme.VylaTextPrimary
|
||||
|
||||
enum class NavDestination(val key: String, val title: String, val icon: ImageVector) {
|
||||
SEARCH("search", "Search", Icons.Default.Search),
|
||||
DISCOVERY("discover", "Discovery", Icons.Default.Explore),
|
||||
MOVIES("movie", "Movies", Icons.Default.Movie),
|
||||
TV_SHOWS("tv", "TV Shows", Icons.Default.Tv),
|
||||
WATCH_LATER("wl", "Watch Later", Icons.Default.Schedule),
|
||||
FAVORITES("fav", "Favorites", Icons.Default.Favorite)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Sidebar(
|
||||
currentDestination: NavDestination,
|
||||
hasWatchLater: Boolean,
|
||||
hasFavorites: Boolean,
|
||||
onNavigate: (NavDestination) -> Unit,
|
||||
onRequestExitSidebar: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var focusedDestination by remember { mutableStateOf<NavDestination?>(null) }
|
||||
val isSidebarFocused = focusedDestination != null
|
||||
|
||||
val animatedWidth by animateDpAsState(
|
||||
targetValue = if (isSidebarFocused) 260.dp else 68.dp,
|
||||
animationSpec = tween(durationMillis = 280),
|
||||
label = "sidebar_width"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(animatedWidth)
|
||||
.zIndex(10f)
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colors = if (isSidebarFocused) {
|
||||
listOf(Color(0xF0000000), Color(0xB3000000), Color.Transparent)
|
||||
} else {
|
||||
listOf(Color(0xCC000000), Color.Transparent)
|
||||
}
|
||||
)
|
||||
)
|
||||
.padding(vertical = 24.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.height(44.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = painterResource(R.drawable.icon),
|
||||
contentDescription = "Vyla Logo",
|
||||
modifier = Modifier.size(32.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isSidebarFocused,
|
||||
enter = fadeIn(tween(200)),
|
||||
exit = fadeOut(tween(150))
|
||||
) {
|
||||
Text(
|
||||
text = "Vyla",
|
||||
color = Color.White,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = (-0.5).sp,
|
||||
modifier = Modifier.padding(start = 14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 10.dp)
|
||||
) {
|
||||
NavDestination.values().forEach { destination ->
|
||||
val shouldShow = when (destination) {
|
||||
NavDestination.WATCH_LATER -> hasWatchLater || currentDestination == NavDestination.WATCH_LATER
|
||||
NavDestination.FAVORITES -> hasFavorites || currentDestination == NavDestination.FAVORITES
|
||||
else -> true
|
||||
}
|
||||
|
||||
if (shouldShow) {
|
||||
SidebarItem(
|
||||
destination = destination,
|
||||
isSelected = currentDestination == destination,
|
||||
isExpanded = isSidebarFocused,
|
||||
onFocusGained = { focusedDestination = destination },
|
||||
onFocusLost = { if (focusedDestination == destination) focusedDestination = null },
|
||||
onSelect = {
|
||||
focusedDestination = null
|
||||
onNavigate(destination)
|
||||
},
|
||||
onRightPress = {
|
||||
focusedDestination = null
|
||||
onRequestExitSidebar()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidebarItem(
|
||||
destination: NavDestination,
|
||||
isSelected: Boolean,
|
||||
isExpanded: Boolean,
|
||||
onFocusGained: () -> Unit,
|
||||
onFocusLost: () -> Unit,
|
||||
onSelect: () -> Unit,
|
||||
onRightPress: () -> Unit
|
||||
) {
|
||||
var isItemFocused by remember { mutableStateOf(false) }
|
||||
|
||||
val shape = RoundedCornerShape(26.dp)
|
||||
val background = when {
|
||||
isItemFocused -> Color.White
|
||||
isSelected && !isExpanded -> Color(0x33FFFFFF)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
val contentColor = when {
|
||||
isItemFocused -> Color.Black
|
||||
isSelected -> Color.White
|
||||
else -> VylaTextMuted
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.clip(shape)
|
||||
.background(background)
|
||||
.onFocusChanged { state ->
|
||||
isItemFocused = state.isFocused
|
||||
if (state.isFocused) onFocusGained() else onFocusLost()
|
||||
}
|
||||
.onKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown) {
|
||||
if (event.key == Key.DirectionRight) {
|
||||
onRightPress()
|
||||
return@onKeyEvent true
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
.focusable()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
onSelect()
|
||||
}
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = destination.title,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isExpanded,
|
||||
enter = fadeIn(tween(200)),
|
||||
exit = fadeOut(tween(150))
|
||||
) {
|
||||
Text(
|
||||
text = destination.title,
|
||||
color = contentColor,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = if (isSelected || isItemFocused) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
497
android/app/src/main/java/tv/vyla/app/ui/screens/BrowseScreen.kt
Normal file
497
android/app/src/main/java/tv/vyla/app/ui/screens/BrowseScreen.kt
Normal file
@@ -0,0 +1,497 @@
|
||||
package tv.vyla.app.ui.screens
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
|
||||
import androidx.compose.foundation.focusable
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
|
||||
import androidx.compose.foundation.lazy.items
|
||||
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
|
||||
import androidx.compose.material3.Icon
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
|
||||
import androidx.compose.ui.Alignment
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
import androidx.compose.ui.draw.blur
|
||||
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
|
||||
import androidx.compose.ui.draw.scale
|
||||
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
|
||||
import androidx.compose.ui.input.key.*
|
||||
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
import tv.vyla.app.api.TmdbApi
|
||||
|
||||
import tv.vyla.app.model.Genre
|
||||
|
||||
import tv.vyla.app.model.MediaItem
|
||||
|
||||
import tv.vyla.app.ui.components.MediaCard
|
||||
|
||||
import tv.vyla.app.ui.components.NavDestination
|
||||
|
||||
import tv.vyla.app.ui.theme.*
|
||||
|
||||
data class GenreRow(
|
||||
|
||||
val title: String,
|
||||
|
||||
val mediaType: String,
|
||||
|
||||
val genreId: Int,
|
||||
|
||||
val items: List<MediaItem>
|
||||
)
|
||||
@Composable
|
||||
|
||||
fun BrowseScreen(
|
||||
mode: NavDestination,
|
||||
onOpenDetail: (MediaItem) -> Unit,
|
||||
onPlay: (MediaItem) -> Unit,
|
||||
onRequestSidebar: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var heroItem by remember { mutableStateOf<MediaItem?>(null) }
|
||||
var heroLogoUrl by remember { mutableStateOf<String?>(null) }
|
||||
var heroJob by remember { mutableStateOf<Job?>(null) }
|
||||
val rows = remember { mutableStateListOf<GenreRow>() }
|
||||
var isLoadingMore by remember { mutableStateOf(false) }
|
||||
var rowIdx by remember { mutableIntStateOf(0) }
|
||||
val movieGenres = remember { mutableStateListOf<Genre>() }
|
||||
val tvGenres = remember { mutableStateListOf<Genre>() }
|
||||
val playButtonFocusRequester = remember { FocusRequester() }
|
||||
fun updateHero(item: MediaItem) {
|
||||
heroJob?.cancel()
|
||||
heroJob = coroutineScope.launch {
|
||||
delay(130)
|
||||
heroItem = item
|
||||
heroLogoUrl = TmdbApi.getLogo(item.mediaType, item.id)
|
||||
}
|
||||
}
|
||||
suspend fun loadMoreRows(count: Int) {
|
||||
if (isLoadingMore) return
|
||||
isLoadingMore = true
|
||||
val isMovieMode = mode == NavDestination.MOVIES
|
||||
val isTvMode = mode == NavDestination.TV_SHOWS
|
||||
val isDiscover = mode == NavDestination.DISCOVERY
|
||||
for (i in 0 until count) {
|
||||
val curI = rowIdx++
|
||||
val isMovie = isMovieMode || (isDiscover && curI % 2 == 0)
|
||||
val list = if (isMovie) movieGenres else tvGenres
|
||||
if (list.isEmpty()) continue
|
||||
val genreIdx = if (isDiscover) curI / 2 else curI
|
||||
val genre = list[genreIdx % list.size]
|
||||
val page = 1 + (genreIdx / list.size) % 15
|
||||
val mt = if (isMovie) "movie" else "tv"
|
||||
val items = TmdbApi.getDiscover(mt, genre.id, page)
|
||||
if (items.isNotEmpty()) {
|
||||
val title = "${genre.name} ${if (isMovie) "Movies" else "TV Shows"}"
|
||||
rows.add(GenreRow(title, mt, genre.id, items))
|
||||
if (heroItem == null && rows.isNotEmpty()) {
|
||||
val first = rows[0].items.firstOrNull()
|
||||
if (first != null) {
|
||||
heroItem = first
|
||||
coroutineScope.launch {
|
||||
heroLogoUrl = TmdbApi.getLogo(first.mediaType, first.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
isLoadingMore = false
|
||||
}
|
||||
LaunchedEffect(mode) {
|
||||
rows.clear()
|
||||
rowIdx = 0
|
||||
heroItem = null
|
||||
heroLogoUrl = null
|
||||
movieGenres.clear()
|
||||
tvGenres.clear()
|
||||
movieGenres.addAll(TmdbApi.getGenreList("movie"))
|
||||
tvGenres.addAll(TmdbApi.getGenreList("tv"))
|
||||
loadMoreRows(5)
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
|
||||
Crossfade(
|
||||
targetState = heroItem?.ambientBackdropUrl,
|
||||
animationSpec = tween(700),
|
||||
label = "ambient_bg"
|
||||
) { url ->
|
||||
if (!url.isNullOrEmpty()) {
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.blur(50.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color(0x0D000000),
|
||||
0.55f to Color(0x8C000000),
|
||||
1.0f to Color(0xF207070A)
|
||||
)
|
||||
)
|
||||
)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 60.dp)
|
||||
) {
|
||||
item {
|
||||
HeroSection(
|
||||
item = heroItem,
|
||||
logoUrl = heroLogoUrl,
|
||||
playButtonFocusRequester = playButtonFocusRequester,
|
||||
onFocusedTop = {
|
||||
coroutineScope.launch {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
},
|
||||
onPlay = {
|
||||
coroutineScope.launch { listState.scrollToItem(0) }
|
||||
heroItem?.let(onPlay)
|
||||
},
|
||||
onInfo = {
|
||||
coroutineScope.launch { listState.scrollToItem(0) }
|
||||
heroItem?.let(onOpenDetail)
|
||||
}
|
||||
)
|
||||
}
|
||||
items(rows) { row ->
|
||||
Column(modifier = Modifier.padding(vertical = 10.dp)) {
|
||||
Text(
|
||||
text = row.title,
|
||||
color = VylaTextPrimary,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 78.dp, bottom = 12.dp)
|
||||
)
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 78.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(row.items) { item ->
|
||||
val isFirstInRow = row.items.firstOrNull() == item
|
||||
MediaCard(
|
||||
item = item,
|
||||
width = 136,
|
||||
onSelect = { onOpenDetail(item) },
|
||||
onFocused = { updateHero(item) },
|
||||
modifier = Modifier.onKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionLeft && isFirstInRow) {
|
||||
onRequestSidebar()
|
||||
return@onKeyEvent true
|
||||
}
|
||||
false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
LaunchedEffect(rows.size) {
|
||||
if (rows.isNotEmpty()) {
|
||||
loadMoreRows(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun HeroSection(
|
||||
item: MediaItem?,
|
||||
logoUrl: String?,
|
||||
playButtonFocusRequester: FocusRequester,
|
||||
onFocusedTop: () -> Unit,
|
||||
onPlay: () -> Unit,
|
||||
onInfo: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(460.dp)
|
||||
) {
|
||||
val backdropUrl = item?.backdropUrl
|
||||
if (!backdropUrl.isNullOrEmpty()) {
|
||||
AsyncImage(
|
||||
model = backdropUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
0.0f to Color.Black,
|
||||
0.45f to Color.Black,
|
||||
1.0f to Color.Transparent
|
||||
),
|
||||
blendMode = BlendMode.DstIn
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
0.0f to Color(0xCC000000),
|
||||
0.40f to Color(0x59000000),
|
||||
0.70f to Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = 78.dp, bottom = 28.dp)
|
||||
.widthIn(max = 640.dp)
|
||||
) {
|
||||
if (item != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(110.dp)
|
||||
.padding(bottom = 12.dp),
|
||||
contentAlignment = Alignment.BottomStart
|
||||
) {
|
||||
if (!logoUrl.isNullOrEmpty()) {
|
||||
AsyncImage(
|
||||
model = logoUrl,
|
||||
contentDescription = item.displayTitle,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.height(110.dp)
|
||||
.widthIn(max = 440.dp)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = item.displayTitle,
|
||||
color = Color.White,
|
||||
fontSize = 38.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = (-1).sp,
|
||||
lineHeight = 42.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.padding(bottom = 10.dp)
|
||||
) {
|
||||
item.voteAverage?.let { rate ->
|
||||
Text(
|
||||
text = "★ ${String.format("%.1f", rate)}",
|
||||
color = Color(0xFFFFD700),
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
if (item.year.isNotEmpty()) {
|
||||
Text(
|
||||
text = item.year,
|
||||
color = Color(0xFFE3E3EE),
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color(0x33FFFFFF))
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (item.mediaType == "tv") "Series" else "Movie",
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
val overview = item.overview
|
||||
if (!overview.isNullOrEmpty()) {
|
||||
Text(
|
||||
text = overview,
|
||||
color = Color(0xFFD4D4E2),
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(bottom = 20.dp)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
HeroPillButton(
|
||||
text = "Play",
|
||||
icon = Icons.Default.PlayArrow,
|
||||
isPrimary = true,
|
||||
focusRequester = playButtonFocusRequester,
|
||||
onFocused = onFocusedTop,
|
||||
onClick = onPlay
|
||||
)
|
||||
HeroPillButton(
|
||||
text = "More Info",
|
||||
icon = Icons.Default.Info,
|
||||
isPrimary = false,
|
||||
onFocused = onFocusedTop,
|
||||
onClick = onInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun HeroPillButton(
|
||||
text: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
isPrimary: Boolean,
|
||||
focusRequester: FocusRequester? = null,
|
||||
onFocused: (() -> Unit)? = null,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1.05f else 1.0f,
|
||||
label = "pill_scale"
|
||||
)
|
||||
val background = when {
|
||||
isPrimary && isFocused -> Color.White
|
||||
isPrimary -> Color.White
|
||||
isFocused -> Color(0x55FFFFFF)
|
||||
else -> VylaPillBackground
|
||||
}
|
||||
val contentColor = when {
|
||||
isPrimary -> Color.Black
|
||||
else -> Color.White
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.scale(scale)
|
||||
.height(48.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(background)
|
||||
.then(
|
||||
if (isFocused && isPrimary) {
|
||||
Modifier.border(2.dp, Color(0xFFCCCCCC), RoundedCornerShape(24.dp))
|
||||
} else if (isFocused) {
|
||||
Modifier.border(2.dp, Color.White, RoundedCornerShape(24.dp))
|
||||
} else Modifier
|
||||
)
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||
.onFocusChanged {
|
||||
isFocused = it.isFocused
|
||||
if (it.isFocused) {
|
||||
onFocused?.invoke()
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
onClick()
|
||||
}
|
||||
.padding(horizontal = 24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
color = contentColor,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
1050
android/app/src/main/java/tv/vyla/app/ui/screens/DetailScreen.kt
Normal file
1050
android/app/src/main/java/tv/vyla/app/ui/screens/DetailScreen.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
package tv.vyla.app.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import tv.vyla.app.model.MediaItem
|
||||
import tv.vyla.app.ui.components.MediaCard
|
||||
import tv.vyla.app.ui.theme.VylaBackground
|
||||
import tv.vyla.app.ui.theme.VylaTextMuted
|
||||
import tv.vyla.app.ui.theme.VylaTextPrimary
|
||||
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
title: String,
|
||||
items: List<MediaItem>,
|
||||
onOpenDetail: (MediaItem) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(VylaBackground)
|
||||
.padding(start = 78.dp, end = 48.dp, top = 36.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = androidx.compose.ui.Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = VylaTextPrimary,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "${items.size} title${if (items.size == 1) "" else "s"}",
|
||||
color = VylaTextMuted,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (items.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 100.dp),
|
||||
contentAlignment = androidx.compose.ui.Alignment.TopStart
|
||||
) {
|
||||
Text(
|
||||
text = "Nothing here yet. Open a title, press the ⋮ button and add it.",
|
||||
color = VylaTextMuted,
|
||||
fontSize = 17.sp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 140.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
contentPadding = PaddingValues(bottom = 60.dp),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(items) { item ->
|
||||
MediaCard(
|
||||
item = item,
|
||||
width = 140,
|
||||
onSelect = { onOpenDetail(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1626
android/app/src/main/java/tv/vyla/app/ui/screens/PlayerScreen.kt
Normal file
1626
android/app/src/main/java/tv/vyla/app/ui/screens/PlayerScreen.kt
Normal file
File diff suppressed because it is too large
Load Diff
631
android/app/src/main/java/tv/vyla/app/ui/screens/SearchScreen.kt
Normal file
631
android/app/src/main/java/tv/vyla/app/ui/screens/SearchScreen.kt
Normal file
@@ -0,0 +1,631 @@
|
||||
package tv.vyla.app.ui.screens
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
|
||||
import androidx.compose.foundation.focusable
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
|
||||
import androidx.compose.foundation.lazy.items
|
||||
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
|
||||
import androidx.compose.material.icons.filled.Backspace
|
||||
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
|
||||
import androidx.compose.material3.Icon
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
|
||||
import androidx.compose.ui.Alignment
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
import androidx.compose.ui.draw.blur
|
||||
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
||||
import androidx.compose.ui.draw.scale
|
||||
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
import androidx.compose.ui.input.key.*
|
||||
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
import androidx.compose.ui.zIndex
|
||||
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
import tv.vyla.app.api.TmdbApi
|
||||
|
||||
import tv.vyla.app.model.MediaItem
|
||||
|
||||
import tv.vyla.app.ui.theme.*
|
||||
@Composable
|
||||
|
||||
fun SearchScreen(
|
||||
onOpenDetail: (MediaItem) -> Unit,
|
||||
onRequestSidebar: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
var isNumbersMode by remember { mutableStateOf(false) }
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
var focusedItem by remember { mutableStateOf<MediaItem?>(null) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var searchJob by remember { mutableStateOf<Job?>(null) }
|
||||
val listState = rememberLazyListState()
|
||||
val trendingMovies = remember { mutableStateListOf<MediaItem>() }
|
||||
val trendingTv = remember { mutableStateListOf<MediaItem>() }
|
||||
val searchResults = remember { mutableStateListOf<MediaItem>() }
|
||||
val letterAFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
trendingMovies.clear()
|
||||
trendingTv.clear()
|
||||
trendingMovies.addAll(TmdbApi.getTrending("movie"))
|
||||
trendingTv.addAll(TmdbApi.getTrending("tv"))
|
||||
delay(120)
|
||||
try {
|
||||
letterAFocusRequester.requestFocus()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
fun performSearch(q: String) {
|
||||
searchJob?.cancel()
|
||||
searchJob = coroutineScope.launch {
|
||||
delay(280)
|
||||
if (q.isBlank()) {
|
||||
searchResults.clear()
|
||||
} else {
|
||||
val res = TmdbApi.searchMulti(q)
|
||||
searchResults.clear()
|
||||
searchResults.addAll(res.filter { !it.posterUrl.isNullOrEmpty() })
|
||||
}
|
||||
}
|
||||
}
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "cursor_blink")
|
||||
val cursorAlpha by infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(500, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "cursor_alpha"
|
||||
)
|
||||
val letters = listOf("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z")
|
||||
val numbersAndSymbols = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0", ".", "-", "'", "&", ":")
|
||||
val searchMovies = searchResults.filter { it.mediaType == "movie" }
|
||||
val searchTv = searchResults.filter { it.mediaType == "tv" }
|
||||
val suggestionNames = remember(query, searchResults.size, trendingMovies.size, trendingTv.size) {
|
||||
val pool = if (query.isNotBlank()) searchResults else (trendingMovies + trendingTv)
|
||||
pool.map { it.displayTitle }.distinct().filter { it.isNotBlank() && it != query }.take(9)
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF07070A))
|
||||
.onKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown) {
|
||||
val code = event.nativeKeyEvent.keyCode
|
||||
if (code == android.view.KeyEvent.KEYCODE_DEL) {
|
||||
if (query.isNotEmpty()) {
|
||||
query = query.dropLast(1)
|
||||
performSearch(query)
|
||||
}
|
||||
return@onKeyEvent true
|
||||
}
|
||||
val char = event.nativeKeyEvent.unicodeChar.toChar()
|
||||
if (char in 'a'..'z' || char in 'A'..'Z' || char in '0'..'9' || char == ' ') {
|
||||
query += char
|
||||
performSearch(query)
|
||||
return@onKeyEvent true
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = focusedItem?.ambientBackdropUrl,
|
||||
animationSpec = tween(600),
|
||||
label = "search_bg"
|
||||
) { url ->
|
||||
if (!url.isNullOrEmpty()) {
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.blur(70.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color(0x33000000),
|
||||
1.0f to Color(0x99000000)
|
||||
)
|
||||
)
|
||||
)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 78.dp, end = 40.dp, top = 26.dp, bottom = 60.dp)
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
tint = Color(0xD9FFFFFF),
|
||||
modifier = Modifier.size(38.dp)
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = if (query.isEmpty()) "Search" else query,
|
||||
color = if (query.isEmpty()) Color(0x80FFFFFF) else Color.White,
|
||||
fontSize = 38.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = (-0.5).sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (query.isNotEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.width(3.dp)
|
||||
.height(30.dp)
|
||||
.background(Color.White.copy(alpha = cursorAlpha))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp, bottom = 4.dp)
|
||||
) {
|
||||
SearchKeyButton(
|
||||
label = if (isNumbersMode) "ABC" else "123",
|
||||
isWord = true,
|
||||
onLeftPress = onRequestSidebar,
|
||||
onClick = {
|
||||
isNumbersMode = !isNumbersMode
|
||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||
}
|
||||
)
|
||||
SearchKeyButton(
|
||||
label = "SPACE",
|
||||
isWord = true,
|
||||
onClick = {
|
||||
if (query.isNotEmpty() && !query.endsWith(" ")) {
|
||||
query += " "
|
||||
performSearch(query)
|
||||
}
|
||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||
}
|
||||
)
|
||||
val activeKeys = if (isNumbersMode) numbersAndSymbols else letters
|
||||
activeKeys.forEachIndexed { idx, keyChar ->
|
||||
val isFirstLetter = !isNumbersMode && idx == 0
|
||||
SearchKeyButton(
|
||||
label = keyChar,
|
||||
isWord = false,
|
||||
focusRequester = if (isFirstLetter) letterAFocusRequester else null,
|
||||
onClick = {
|
||||
query += keyChar
|
||||
performSearch(query)
|
||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||
}
|
||||
)
|
||||
}
|
||||
SearchKeyButton(
|
||||
label = "",
|
||||
isWord = false,
|
||||
isDelete = true,
|
||||
onClick = {
|
||||
if (query.isNotEmpty()) {
|
||||
query = query.dropLast(1)
|
||||
performSearch(query)
|
||||
}
|
||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (query.isNotEmpty() || suggestionNames.isNotEmpty()) {
|
||||
item {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (query.isNotEmpty()) {
|
||||
item {
|
||||
SearchChip(
|
||||
text = "\"$query\"",
|
||||
showSearchIcon = true,
|
||||
onClick = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
items(suggestionNames) { name ->
|
||||
SearchChip(
|
||||
text = name,
|
||||
showSearchIcon = false,
|
||||
onClick = {
|
||||
query = name
|
||||
performSearch(query)
|
||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 18.dp)) {
|
||||
HorizontalDivider(color = Color(0x38FFFFFF), thickness = 1.dp)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceAround
|
||||
) {
|
||||
listOf("Top Results", "Movies", "TV Shows").forEachIndexed { idx, title ->
|
||||
SearchTabButton(
|
||||
title = title,
|
||||
isSelected = selectedTab == idx,
|
||||
onClick = { selectedTab = idx }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val sections: List<Pair<String, List<MediaItem>>> = when (selectedTab) {
|
||||
1 -> listOf("Movies" to searchMovies)
|
||||
2 -> listOf("TV Shows" to searchTv)
|
||||
else -> {
|
||||
if (query.isNotEmpty()) {
|
||||
listOf(
|
||||
"Top Matches" to searchResults.take(10),
|
||||
"Movies" to searchMovies,
|
||||
"TV Shows" to searchTv
|
||||
).filter { it.second.isNotEmpty() }
|
||||
} else {
|
||||
listOf(
|
||||
"Trending Movies" to trendingMovies,
|
||||
"Trending TV Shows" to trendingTv
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sections.isEmpty() || sections.all { it.second.isEmpty() }) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 48.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (query.isNotEmpty()) "No results for \"$query\"" else "No results found",
|
||||
color = Color(0x88FFFFFF),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(sections) { (secTitle, itemsList) ->
|
||||
if (itemsList.isNotEmpty()) {
|
||||
Column(modifier = Modifier.padding(bottom = 24.dp)) {
|
||||
Text(
|
||||
text = secTitle,
|
||||
color = Color(0xCCFFFFFF),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(itemsList) { item ->
|
||||
SearchMediaCard(
|
||||
item = item,
|
||||
onSelect = { onOpenDetail(item) },
|
||||
onFocused = { focusedItem = item }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun SearchKeyButton(
|
||||
label: String,
|
||||
isWord: Boolean,
|
||||
isDelete: Boolean = false,
|
||||
focusRequester: FocusRequester? = null,
|
||||
onLeftPress: (() -> Unit)? = null,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(if (isFocused) 1.22f else 1.0f, label = "key_scale")
|
||||
val bg = when {
|
||||
isFocused -> Color.White
|
||||
isWord -> Color(0x38FFFFFF)
|
||||
else -> Color(0x1AFFFFFF)
|
||||
}
|
||||
val contentColor = if (isFocused) Color.Black else Color(0xE0FFFFFF)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.scale(scale)
|
||||
.height(if (isWord) 34.dp else 40.dp)
|
||||
.then(
|
||||
if (isWord) Modifier.padding(horizontal = 2.dp)
|
||||
else Modifier.width(36.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(bg)
|
||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.onKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT && onLeftPress != null) {
|
||||
onLeftPress()
|
||||
return@onKeyEvent true
|
||||
}
|
||||
false
|
||||
}
|
||||
.focusable()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { onClick() }
|
||||
.padding(horizontal = if (isWord) 12.dp else 0.dp)
|
||||
.zIndex(if (isFocused) 5f else 1f)
|
||||
) {
|
||||
if (isDelete) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Backspace,
|
||||
contentDescription = "Backspace",
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = label,
|
||||
color = contentColor,
|
||||
fontSize = if (isWord) 11.sp else 18.sp,
|
||||
fontWeight = if (isWord) FontWeight.Bold else FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun SearchChip(
|
||||
text: String,
|
||||
showSearchIcon: Boolean = false,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(if (isFocused) 1.06f else 1.0f, label = "chip_scale")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.scale(scale)
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (isFocused) Color.White else Color(0x3DFFFFFF))
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.focusable()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { onClick() }
|
||||
.padding(horizontal = 20.dp)
|
||||
) {
|
||||
if (showSearchIcon) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
tint = if (isFocused) Color.Black else Color.White,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
color = if (isFocused) Color.Black else Color.White,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun SearchTabButton(
|
||||
title: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (isFocused) Color(0x33FFFFFF) else Color.Transparent)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.focusable()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { onClick() }
|
||||
.padding(horizontal = 26.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = if (isSelected || isFocused) Color.White else Color(0xA6FFFFFF),
|
||||
fontSize = 15.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(28.dp)
|
||||
.height(2.dp)
|
||||
.background(if (isSelected) Color.White else Color.Transparent)
|
||||
)
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
private fun SearchMediaCard(
|
||||
item: MediaItem,
|
||||
onSelect: () -> Unit,
|
||||
onFocused: () -> Unit
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(if (isFocused) 1.05f else 1.0f, label = "card_scale")
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(140.dp)
|
||||
.scale(scale)
|
||||
.offset(y = if (isFocused) (-3).dp else 0.dp)
|
||||
.onFocusChanged {
|
||||
isFocused = it.isFocused
|
||||
if (it.isFocused) {
|
||||
onFocused()
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { onSelect() }
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(2f / 3f)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color(0xFF1B1B24))
|
||||
.then(
|
||||
if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(14.dp))
|
||||
else Modifier
|
||||
)
|
||||
) {
|
||||
val posterUrl = item.posterUrl
|
||||
if (!posterUrl.isNullOrEmpty()) {
|
||||
AsyncImage(
|
||||
model = posterUrl,
|
||||
contentDescription = item.displayTitle,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = item.displayTitle,
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 10.dp)
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
) {
|
||||
item.voteAverage?.let { v ->
|
||||
Text(
|
||||
text = "★ ${String.format("%.1f", v)}",
|
||||
color = Color(0x9EFFFFFF),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Text(text = "•", color = Color(0x50FFFFFF), fontSize = 12.sp)
|
||||
}
|
||||
if (item.year.isNotEmpty()) {
|
||||
Text(
|
||||
text = item.year,
|
||||
color = Color(0x9EFFFFFF),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Text(text = "•", color = Color(0x50FFFFFF), fontSize = 12.sp)
|
||||
}
|
||||
Text(
|
||||
text = if (item.mediaType == "tv") "Show" else "Movie",
|
||||
color = Color(0x9EFFFFFF),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
73
android/app/src/main/java/tv/vyla/app/ui/theme/Theme.kt
Normal file
73
android/app/src/main/java/tv/vyla/app/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,73 @@
|
||||
package tv.vyla.app.ui.theme
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.darkColorScheme
|
||||
|
||||
val VylaBackground = Color(0xFF07070A)
|
||||
val VylaCardBackground = Color(0xFF1B1B24)
|
||||
val VylaTextPrimary = Color(0xFFF2F2F5)
|
||||
val VylaTextSecondary = Color(0xFFD4D4E2)
|
||||
val VylaTextMuted = Color(0x99FFFFFF)
|
||||
val VylaPillBackground = Color(0x26FFFFFF)
|
||||
val VylaPillBackgroundFocused = Color(0x4DFFFFFF)
|
||||
val VylaYellow = Color(0xFFFFD700)
|
||||
val VylaCyan = Color(0xFF00FFFF)
|
||||
|
||||
private val VylaTvColorScheme = darkColorScheme(
|
||||
background = VylaBackground,
|
||||
surface = VylaCardBackground,
|
||||
primary = Color.White,
|
||||
onPrimary = Color.Black,
|
||||
onBackground = VylaTextPrimary,
|
||||
onSurface = VylaTextPrimary
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun VylaTvTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = VylaTvColorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.tvFocusable(
|
||||
isFocused: Boolean,
|
||||
scaleAmount: Float = 1.06f,
|
||||
shape: Shape = RoundedCornerShape(14.dp),
|
||||
focusedBorderWidth: Dp = 2.dp,
|
||||
focusedBorderColor: Color = Color.White
|
||||
): Modifier {
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) scaleAmount else 1.0f,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
label = "tv_scale"
|
||||
)
|
||||
|
||||
return this
|
||||
.scale(scale)
|
||||
.then(
|
||||
if (isFocused) {
|
||||
Modifier
|
||||
.shadow(16.dp, shape = shape, spotColor = Color(0x66FFFFFF))
|
||||
.border(BorderStroke(focusedBorderWidth, focusedBorderColor), shape)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
}
|
||||
BIN
android/app/src/main/res/drawable-v24/icon.png
Normal file
BIN
android/app/src/main/res/drawable-v24/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
android/app/src/main/res/drawable/icon.png
Normal file
BIN
android/app/src/main/res/drawable/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -1,22 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">#07070A</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
<item name="android:windowBackground">#07070A</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,5 +1,3 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
@@ -8,10 +6,8 @@ buildscript {
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.2.1'
|
||||
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22'
|
||||
classpath 'com.google.gms:google-services:4.4.0'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
@@ -1,5 +1 @@
|
||||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
include ':app'
|
||||
Reference in New Issue
Block a user