rewrote the entire app using jetpack compose, and exoplayer/media3 for playback

This commit is contained in:
2026-10-05 11:00:55 -07:00
parent ecbf5953d6
commit a0621f725a
75 changed files with 6038 additions and 8059 deletions

View File

@@ -1,7 +1,12 @@
To apply changes to the app, run `npm run dev` to build the APK and `npm run android` to sync the changes to the android project.
To build the native Android TV app APK, run:
```bash
npm run dev
```
This builds the Vyla JS resolver asset and compiles the native Android TV app via `./gradlew assembleDebug` to produce `vyla.apk`.
To release a new version, run
To open and develop in Android Studio, open the `android` folder directly.
To release a new version:
```bash
npm version patch
git push origin main

View File

@@ -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"
}

View File

@@ -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());

View File

@@ -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>

File diff suppressed because one or more lines are too long

View File

@@ -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());
}
}
}

View File

@@ -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);
}
}

View 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)
)
}
}
}
}
}
}

View 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()
}
}
}

View File

@@ -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();
}
}
});
}
}

View 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()
}
}
}

View 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
}
}
}

View 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
}
}

View 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"
)

View 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)
}
}
}

View 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) {}
}
}
}
}
}

View File

@@ -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
)
}
}
}
}

View 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)
)
}
}
}

View 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
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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) }
)
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View 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
)
}
}
}

View 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
}
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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')

View File

@@ -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'

View File

@@ -1,9 +0,0 @@
{
"appId": "tv.vyla.app",
"appName": "Vyla",
"webDir": "www",
"server": {
"androidScheme": "https",
"cleartext": true
}
}

View File

@@ -1,327 +0,0 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
outline: none;
-webkit-tap-highlight-color: transparent;
-webkit-user-select: none;
user-select: none;
}
:root {
--pad: clamp(56px, 6vw, 132px);
--cw: clamp(120px, 9.4vw, 200px);
}
html,
body {
height: 100%;
background: #07070a;
color: #f2f2f5;
font-family: "Inter", "SF Pro Display", "Segoe UI", Roboto, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
overflow: hidden;
cursor: default;
overscroll-behavior: none;
touch-action: pan-x pan-y;
}
.card,
.sc,
.pe,
.tr,
.ep {
transform: translateZ(0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
::-webkit-scrollbar {
display: none
}
.f {
outline: none;
cursor: pointer
}
.xf {
position: absolute;
inset: 0;
background-size: cover;
background-position: center top;
opacity: 0;
transition: opacity .7s ease
}
.xf.in {
opacity: 1
}
#bg {
position: fixed;
inset: -40px;
background: #07070a;
filter: blur(42px) brightness(.55) saturate(1)
}
#dim {
position: fixed;
inset: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, .05) 0%, rgba(0, 0, 0, .55) 55%, #000 100%)
}
#dim2 {
position: fixed;
inset: 0;
background: #000;
opacity: 0;
pointer-events: none
}
#main {
position: fixed;
inset: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none
}
.hero {
position: relative;
height: 60vh
}
#hb {
position: absolute;
inset: 0;
-webkit-mask-image: linear-gradient(180deg, #000 50%, transparent 100%);
mask-image: linear-gradient(180deg, #000 50%, transparent 100%)
}
#hb::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(0, 0, 0, .8) 0%, rgba(0, 0, 0, .35) 40%, transparent 70%)
}
.hc {
position: absolute;
left: var(--pad);
bottom: 5vh;
width: min(48vw, 680px);
z-index: 2
}
#hlogo {
height: 16vh;
display: flex;
align-items: flex-end;
margin-bottom: 2vh
}
#hlogo img {
max-width: min(34vw, 440px);
max-height: 16vh;
object-fit: contain;
filter: drop-shadow(0 4px 18px rgba(0, 0, 0, .6))
}
#hlogo h1 {
font-size: clamp(28px, 3.6vw, 64px);
font-weight: 700;
letter-spacing: -.02em;
line-height: 1.02;
text-shadow: 0 4px 24px rgba(0, 0, 0, .6)
}
.meta {
display: flex;
gap: 14px;
align-items: center;
font-size: clamp(13px, 1.05vw, 19px);
color: #e3e3ee;
margin-bottom: 1.4vh;
font-weight: 500
}
.meta .b {
background: rgba(255, 255, 255, .18);
border-radius: 6px;
padding: 2px 8px;
font-size: .85em;
font-weight: 600
}
.ov1 {
font-size: clamp(13px, 1.1vw, 20px);
line-height: 1.5;
color: #d4d4e2;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 2.4vh;
max-width: 640px
}
.hb {
display: flex;
gap: 14px;
align-items: center
}
.pb,
.ib {
border: 0;
font: inherit;
font-weight: 600;
font-size: clamp(14px, 1.1vw, 20px);
border-radius: 999px;
height: clamp(44px, 3.6vw, 64px);
padding: 0 clamp(28px, 2.6vw, 48px);
display: inline-flex;
align-items: center;
gap: 10px;
transition: transform .2s, background .2s
}
.pb {
background: #fff;
color: #000
}
.ib {
background: rgba(255, 255, 255, .16);
color: #fff;
backdrop-filter: blur(12px);
padding: 0 clamp(20px, 2vw, 36px)
}
.pb.on,
.ib.on {
transform: scale(1.04);
}
.ib.on {
background: rgba(255, 255, 255, .3)
}
.sec {
padding-top: 10px
}
.sec h2 {
padding: 0 var(--pad);
font-size: clamp(16px, 1.5vw, 26px);
font-weight: 700;
margin-bottom: 4px;
text-shadow: 0 2px 10px rgba(0, 0, 0, .6)
}
.row {
display: flex;
gap: 16px;
overflow-x: auto;
overflow-y: hidden;
padding: 22px var(--pad) 26px;
position: relative;
scrollbar-width: none;
min-height: calc(var(--cw)*1.5 + 48px)
}
.card {
flex: 0 0 var(--cw);
height: calc(var(--cw)*1.5);
border-radius: 14px;
overflow: hidden;
background: #1b1b24;
position: relative;
transform: translateZ(0);
transition: transform .38s cubic-bezier(.22, .9, .3, 1);
will-change: transform
}
.card img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
filter: brightness(.86);
transition: filter .38s
}
.card::after {
content: "";
position: absolute;
inset: 0;
z-index: 3;
border-radius: inherit;
opacity: 0;
transition: opacity .25s;
pointer-events: none
}
.card.on {
transform: scale(1.06) translateY(-4px);
z-index: 2
}
.card.on img {
filter: none
}
.card.on::after {
opacity: 1
}
.kbm,
.kbm * {
cursor: none !important
}
#sentinel {
height: 40vh
}
.none {
color: rgba(255, 255, 255, .6);
padding: 5vh 0;
font-size: 18px
}
.toast {
position: fixed;
left: 50%;
bottom: 7vh;
transform: translateX(-50%);
z-index: 200;
background: #fff;
color: #000;
padding: 12px 22px;
border-radius: 999px;
font-weight: 600;
font-size: 15px;
animation: tin .25s both, tout .3s 1.9s forwards
}
@keyframes tin {
from {
opacity: 0;
transform: translate(-50%, 14px)
}
to {
opacity: 1;
transform: translate(-50%, 0)
}
}
@keyframes tout {
to {
opacity: 0
}
}

View File

@@ -1,447 +0,0 @@
#ov {
position: fixed;
inset: 0;
z-index: 50;
background: #000;
overflow-y: auto;
opacity: 0;
pointer-events: none;
transform: scale(1.04);
transition: opacity .35s, transform .35s;
scrollbar-width: none
}
#ov.show {
opacity: 1;
pointer-events: auto;
transform: none
}
.dbl {
position: fixed;
inset: -60px;
background-size: cover;
background-position: center;
filter: blur(60px) brightness(.5) saturate(1)
}
.dw {
position: relative;
min-height: 100vh
}
.dimg {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 82vh;
background-size: cover;
background-position: center top;
-webkit-mask-image: linear-gradient(180deg, #000 45%, transparent 100%);
mask-image: linear-gradient(180deg, #000 45%, transparent 100%)
}
.dc {
position: relative;
padding: 40vh 0 8vh
}
.dc>.dh {
padding: 0 6vw
}
.dh #dlogo {
height: 20vh;
display: flex;
align-items: flex-end;
margin-bottom: 2vh
}
.dh #dlogo img {
max-width: min(40vw, 540px);
max-height: 20vh;
object-fit: contain;
filter: drop-shadow(0 4px 18px rgba(0, 0, 0, .6))
}
#dback {
position: fixed;
top: 3.5vh;
left: 3.5vw;
z-index: 12;
background: rgba(255, 255, 255, .12);
backdrop-filter: blur(12px);
}
#dback.on {
background: #fff;
color: #000;
transform: scale(1.08);
}
.dh h1 {
font-size: clamp(30px, 4.4vw, 76px);
font-weight: 700;
letter-spacing: -.02em;
line-height: 1
}
.dh .meta {
margin-top: 1vh
}
.dh .hb {
margin: 2vh 0
}
.circ {
width: clamp(44px, 3.6vw, 64px);
height: clamp(44px, 3.6vw, 64px);
border-radius: 50%;
background: rgba(255, 255, 255, .12);
backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
}
.circ.on {
transform: scale(1.1);
}
.otx {
max-width: min(70vw, 980px);
font-size: clamp(13px, 1.1vw, 20px);
line-height: 1.55;
color: #d9d9e4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden
}
.otx.x {
-webkit-line-clamp: unset;
line-clamp: unset;
display: block
}
.more {
display: inline-block;
margin-top: 10px;
background: rgba(255, 255, 255, .18);
border-radius: 999px;
padding: 5px 14px;
font-size: 13px;
font-weight: 700;
}
.more.on {
transform: scale(1.08);
}
.ds {
margin-top: 3vh
}
.ds h3 {
padding: 0 6vw;
font-size: clamp(17px, 1.5vw, 26px);
font-weight: 700
}
.ds .row {
padding-left: 6vw;
padding-right: 6vw;
min-height: 0
}
.tr {
flex: 0 0 clamp(250px, 21vw, 400px);
border-radius: 20px;
position: relative;
overflow: hidden;
background: #101016;
padding: 0 0 18px;
isolation: isolate;
}
.tr .tb {
position: absolute;
inset: -40px;
background-size: cover;
background-position: center;
filter: blur(34px) brightness(.62) saturate(1.35);
transform: scale(1.2);
z-index: -1
}
.tr .th {
aspect-ratio: 16/9;
background-size: cover;
background-position: center;
margin: 0 0 -26px;
-webkit-mask-image: linear-gradient(180deg, #000 50%, rgba(0, 0, 0, .55) 74%, transparent 100%);
mask-image: linear-gradient(180deg, #000 50%, rgba(0, 0, 0, .55) 74%, transparent 100%)
}
.tr b,
.tr small {
display: block;
padding: 0 18px;
position: relative
}
.tr b {
font-size: clamp(14px, 1.1vw, 19px);
font-weight: 600
}
.tr small {
color: #d0d0da;
margin-top: 5px
}
.tr.on {
transform: scale(1.04);
}
.cs {
flex: 0 0 clamp(96px, 7.6vw, 150px);
text-align: center;
transition: transform .22s
}
.cs .pf {
width: clamp(90px, 7vw, 130px);
height: clamp(90px, 7vw, 130px);
border-radius: 50%;
margin: 0 auto 10px;
background: #2a2a3c center/cover;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 700;
}
.cs b {
display: block;
font-size: clamp(12px, .95vw, 16px)
}
.cs small {
display: block;
color: #aaa;
font-size: clamp(11px, .8vw, 14px);
margin-top: 2px
}
.cs.on {
transform: scale(1.07)
}
.circ {
position: relative
}
.circ svg {
width: 46%;
height: 46%
}
.circ.sv {
background: #fff;
color: #000
}
#dfv.sv svg {
fill: currentColor
}
.circ[data-l].on::after {
content: attr(data-l);
position: absolute;
top: calc(100% + 12px);
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
font-size: 13px;
font-weight: 600;
background: #fff;
color: #000;
padding: 5px 12px;
border-radius: 999px;
z-index: 5;
pointer-events: none
}
.mx {
display: none;
gap: 14px;
align-items: center
}
.mx.open {
display: flex
}
.mx.open .circ {
animation: pop .32s cubic-bezier(.22, .9, .3, 1) backwards
}
.mx.open .circ+.circ {
animation-delay: .06s
}
@keyframes pop {
from {
opacity: 0;
transform: scale(.4) translateX(-24px)
}
}
.eps {
padding: 0 6vw
}
.eh {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 14px
}
.eps .eh h3 {
padding: 0
}
.dd {
position: relative;
z-index: 6
}
.ddb {
display: flex;
align-items: center;
gap: 10px;
height: 42px;
padding: 0 18px;
border-radius: 999px;
background: rgba(255, 255, 255, .16);
backdrop-filter: blur(12px);
font-weight: 600;
font-size: clamp(13px, 1vw, 17px);
transition: background .2s, color .2s, transform .25s
}
.ddb svg {
width: 16px;
height: 16px
}
.ddb.on {
background: #fff;
color: #000;
transform: scale(1.04)
}
.ddl {
display: none;
position: absolute;
top: calc(100% + 8px);
left: 0;
min-width: 240px;
max-height: 46vh;
overflow-y: auto;
padding: 6px;
border-radius: 16px;
background: rgba(22, 22, 30, .97);
scrollbar-width: none
}
.ddl.open {
display: block
}
.ddi {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 12px 14px;
border-radius: 10px;
font-weight: 500;
white-space: nowrap
}
.ddi small {
color: rgba(255, 255, 255, .5)
}
.ddi.on {
background: #fff;
color: #000
}
.ddi.on small {
color: rgba(0, 0, 0, .55)
}
.el {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(clamp(210px, 17vw, 320px), 1fr));
gap: 28px 20px;
padding: 8px 6px 14px;
margin: 0 -6px
}
.el .none {
grid-column: 1/-1
}
.ep {
min-width: 0
}
.ep .et {
position: relative;
aspect-ratio: 16/9;
border-radius: 10px;
overflow: hidden;
background: #000;
}
.ep .et img {
width: 100%;
height: 100%;
object-fit: cover;
display: block
}
.ep b {
display: block;
margin-top: 12px;
font-size: clamp(13px, 1vw, 16px);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis
}
.ep small {
display: block;
margin-top: 3px;
font-size: clamp(12px, .9vw, 14px);
color: rgba(255, 255, 255, .5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis
}
.ep.on .et {
transform: scale(1.03)
}

View File

@@ -1,65 +0,0 @@
#lib {
position: fixed;
inset: 0;
z-index: 25;
background: linear-gradient(180deg, rgba(0, 0, 0, .25), rgba(0, 0, 0, .7));
overflow-y: auto;
opacity: 0;
pointer-events: none;
transition: opacity .35s;
padding: 9vh var(--pad) 10vh;
scrollbar-width: none
}
#lib.show {
opacity: 1;
pointer-events: auto
}
.lib #main {
visibility: hidden
}
.lib #bg {
filter: blur(70px) brightness(.5) saturate(1.5)
}
.lib #dim {
opacity: .35
}
.lib #dim2 {
opacity: 0 !important
}
#lib h1 {
font-size: clamp(28px, 3.4vw, 56px);
font-weight: 700;
letter-spacing: -.02em;
display: flex;
align-items: baseline;
gap: 18px
}
#lib h1 small {
font-size: clamp(13px, 1.1vw, 19px);
font-weight: 500;
color: rgba(255, 255, 255, .6);
letter-spacing: 0
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--cw), 1fr));
gap: 22px;
padding: 30px 10px
}
.grid .card {
height: auto;
aspect-ratio: 2/3
}
.grid .none {
grid-column: 1/-1
}

View File

@@ -1,705 +0,0 @@
#pl {
position: fixed;
inset: 0;
z-index: 80;
background: #000;
opacity: 0;
pointer-events: none;
transition: opacity .35s;
}
#pl.show {
opacity: 1;
pointer-events: auto;
}
#pl:not(.ui) {
cursor: none;
}
#pv {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
z-index: 0;
}
#pl.cover #pv {
object-fit: cover;
}
video::cue {
font: 600 clamp(16px, 1.8vw, 30px) Inter, sans-serif;
background: rgba(0, 0, 0, .55);
color: #fff;
}
.sub-active::cue {
color: var(--sub-color, #ffffff) !important;
background-color: var(--sub-bg, rgba(0, 0, 0, 0.75)) !important;
font-size: var(--sub-size, 22px) !important;
text-shadow: var(--sub-shadow, 0 2px 4px rgba(0, 0, 0, 0.8)) !important;
}
.sub-ctrl-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
font-size: 14px;
}
.sub-ctrl-row span {
color: rgba(255, 255, 255, 0.8);
}
.sub-btn-group {
display: flex;
gap: 6px;
}
.sub-btn-group button {
background: rgba(255, 255, 255, 0.15);
border: none;
color: #fff;
padding: 4px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.sub-btn-group button.active {
background: #fff;
color: #000;
font-weight: 600;
}
.src-group {
display: flex;
flex-direction: column;
margin-bottom: 4px;
}
.src-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 14px;
border-radius: 12px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
background: rgba(255, 255, 255, 0.08);
transition: background 0.15s, color 0.15s;
}
.src-header.on,
.src-header:hover {
background: #fff;
color: #000;
}
.src-expand-btn {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.16);
color: inherit;
margin-left: 8px;
}
.src-sublist {
display: none;
flex-direction: column;
gap: 2px;
padding-left: 14px;
margin: 4px 0;
}
.src-sublist.open {
display: flex;
}
.src-subitem {
display: flex;
align-items: center;
justify-content: space-between;
height: 38px;
padding: 0 12px;
border-radius: 8px;
font-size: 13px;
background: rgba(255, 255, 255, 0.04);
cursor: pointer;
}
.src-subitem.on,
.src-subitem:hover {
background: rgba(255, 255, 255, 0.25);
color: #fff;
}
#pl .top,
#pl .bot,
#pmid {
opacity: 0;
transition: opacity .3s, transform .25s, background .2s;
}
#pl.ui .top,
#pl.ui .bot,
#pl.ui #pmid {
opacity: 1;
}
#pl .f {
pointer-events: auto;
}
#pl:not(.ui) .f {
pointer-events: none;
}
#pl .top,
#pl .bot {
position: absolute;
left: 0;
right: 0;
pointer-events: none;
}
#pl .top {
top: 0;
padding: 2.6vh 2.4vw 14vh;
display: flex;
align-items: flex-start;
background: linear-gradient(180deg, rgba(0, 0, 0, .65), transparent);
}
#pl .bot {
bottom: 0;
padding: 10vh 2.4vw 2.6vh;
display: flex;
align-items: center;
gap: 18px;
background: linear-gradient(0deg, rgba(0, 0, 0, .7), transparent);
}
.pic {
width: clamp(44px, 3.3vw, 56px);
height: clamp(44px, 3.3vw, 56px);
flex: 0 0 auto;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: background .2s, color .2s, transform .2s;
}
.pic svg {
width: 52%;
height: 52%;
}
.pic.on {
background: #fff;
color: #000;
transform: scale(1.08);
}
.av {
width: 46px;
height: 46px;
margin: 3px 12px 0 6px;
border-radius: 50%;
object-fit: cover;
background: #2a2a34;
}
.pt {
height: 46px;
margin-top: 3px;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.pt b {
font-size: clamp(15px, 1.2vw, 19px);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 36vw;
}
.pt small {
font-size: clamp(12px, .9vw, 14px);
color: rgba(255, 255, 255, .7);
margin-top: 2px;
}
.pr {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
#pmid {
position: absolute;
left: 50%;
top: 50%;
width: 112px;
height: 112px;
margin: -56px 0 0 -56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
#pmid svg {
width: 72px;
height: 72px;
filter: drop-shadow(0 2px 14px rgba(0, 0, 0, .45));
}
#pmid.on {
background: rgba(255, 255, 255, .18);
transform: scale(1.08);
}
.bt {
font-size: 14px;
font-variant-numeric: tabular-nums;
min-width: 44px;
color: #fff;
}
#pdur {
text-align: right;
}
.sk {
flex: 1;
height: 40px;
position: relative;
}
.sk::before,
.sk u,
.sk i {
content: "";
position: absolute;
left: 0;
top: 50%;
height: 4px;
margin-top: -2px;
border-radius: 2px;
transition: height .15s, margin .15s;
}
.sk::before {
right: 0;
background: rgba(255, 255, 255, .2);
}
.sk u {
width: 0;
background: rgba(255, 255, 255, .38);
}
.sk i {
width: 0;
background: #fff;
}
.sk b {
position: absolute;
top: 50%;
left: 0;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border-radius: 50%;
background: #fff;
transition: transform .15s;
}
.sk.on::before,
.sk.on u,
.sk.on i {
height: 6px;
margin-top: -3px;
}
.sk.on b {
transform: scale(1.35);
}
#pm {
display: none;
position: absolute;
top: calc(2.6vh + 66px);
right: 2.4vw;
width: 320px;
max-height: 70vh;
overflow-y: auto;
padding: 10px;
border-radius: 18px;
background: rgba(0, 0, 0, .94);
backdrop-filter: blur(24px);
z-index: 5;
pointer-events: auto;
scrollbar-width: none;
}
#pm.open {
display: block;
}
#pm h4 {
font-size: 12px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: rgba(255, 255, 255, .5);
padding: 10px 12px 6px;
}
#pm p {
font-size: 11px;
line-height: 1.4;
color: rgba(255, 255, 255, .4);
padding: 10px 12px 4px;
}
.pi {
display: flex;
align-items: center;
justify-content: space-between;
height: 48px;
padding: 0 14px;
border-radius: 12px;
font-size: 15px;
font-weight: 500;
transition: background .15s, color .15s;
}
.pi svg {
width: 20px;
height: 20px;
}
.pi.on {
background: #fff;
color: #000;
}
.vrow {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 6px 8px;
}
.vrow span {
width: 30px;
text-align: right;
font-size: 13px;
color: rgba(255, 255, 255, .7);
}
.vs {
flex: 1;
height: 36px;
position: relative;
border-radius: 8px;
}
.vs::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 4px;
margin-top: -2px;
border-radius: 2px;
background: rgba(255, 255, 255, .2);
}
.vs i {
position: absolute;
left: 0;
top: 50%;
height: 4px;
margin-top: -2px;
border-radius: 2px;
background: #fff;
}
.skip {
display: none;
height: 48px;
padding: 0 26px;
border-radius: 999px;
background: rgba(255, 255, 255, .18);
backdrop-filter: blur(16px);
align-items: center;
font-weight: 600;
transition: transform .25s, background .2s, color .2s;
}
.skip.v {
display: flex;
}
.skip.on {
background: #fff;
color: #000;
transform: scale(1.06);
}
#pact {
position: absolute;
right: 2.4vw;
bottom: 16vh;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
}
.skip svg {
width: 18px;
height: 18px;
margin-left: 10px;
}
#pnext {
background: #fff;
color: #000;
}
#pnext.on {
color: #000;
}
#pl.eps .bot,
#pl.eps #pmid,
#pl.eps #pact {
opacity: 0 !important;
visibility: hidden;
pointer-events: none;
}
#pep {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 9vh 0 3.4vh;
background: linear-gradient(0deg, rgba(0, 0, 0, .97) 60%, rgba(0, 0, 0, .8) 82%, transparent);
transform: translateY(40px);
opacity: 0;
visibility: hidden;
transition: transform .45s cubic-bezier(.22, .9, .3, 1), opacity .3s, visibility 0s .45s;
}
#pl.eps #pep {
transform: none;
opacity: 1;
visibility: visible;
transition-delay: 0s;
}
.peh {
display: flex;
align-items: center;
gap: 22px;
padding: 0 2.4vw;
}
.peh h3 {
font-size: clamp(18px, 1.6vw, 26px);
font-weight: 700;
display: flex;
align-items: baseline;
gap: 14px;
white-space: nowrap;
}
.peh h3 small {
font-size: .62em;
font-weight: 500;
color: rgba(255, 255, 255, .55);
max-width: 22vw;
overflow: hidden;
text-overflow: ellipsis;
}
#pep #pseas {
flex: 1;
min-width: 0;
padding: 4px;
margin: 0;
min-height: 0;
gap: 10px;
}
#pep .chip {
height: 38px;
padding: 0 18px;
font-size: 14px;
background: rgba(255, 255, 255, .12);
}
#pep .chip.act {
background: rgba(255, 255, 255, .3);
}
#pep .chip.on {
background: #fff;
color: #000;
transform: scale(1.05);
}
#pep .pcar {
padding: 18px 2.4vw 8px;
min-height: 0;
gap: 18px;
-webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 5vw), transparent);
mask-image: linear-gradient(90deg, #000 calc(100% - 5vw), transparent);
}
.pe {
flex: 0 0 clamp(230px, 19vw, 330px);
min-width: 0;
}
.pe .et {
position: relative;
aspect-ratio: 16/9;
border-radius: 12px;
overflow: hidden;
background: #000;
}
.pe .et img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pe .et>i {
position: absolute;
left: 10px;
top: 10px;
font-style: normal;
font-size: 12px;
font-weight: 700;
padding: 3px 9px;
border-radius: 999px;
background: rgba(0, 0, 0, .6);
backdrop-filter: blur(8px);
}
.pe .pn {
display: block;
margin-top: 11px;
font-size: clamp(13px, 1vw, 16px);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pe small {
display: block;
margin-top: 3px;
font-size: 13px;
color: rgba(255, 255, 255, .5);
}
.pe.on .et {
transform: scale(1.04);
}
.pe .pg {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, .22);
}
.pe .pg u,
.eq u {
display: block;
text-decoration: none;
}
.pe .pg u {
height: 100%;
width: var(--p, 0%);
}
.eq {
position: absolute;
right: 10px;
bottom: 14px;
display: flex;
gap: 3px;
align-items: flex-end;
height: 16px;
}
.eq u {
width: 3px;
height: 4px;
border-radius: 2px;
animation: eq 1s ease-in-out infinite;
}
.eq u:nth-child(2) {
animation-delay: .2s;
}
.eq u:nth-child(3) {
animation-delay: .4s;
}
@keyframes eq {
50% {
height: 16px;
}
}
#pbg {
position: absolute;
inset: -60px;
width: calc(100% + 120px);
height: calc(100% + 120px);
object-fit: cover;
filter: blur(54px) brightness(0.42) saturate(1.45);
transform: scale(1.08);
pointer-events: none;
z-index: 0;
opacity: 1;
transition: opacity 0.4s ease;
}
#pl.cover #pbg {
opacity: 0;
visibility: hidden;
}

View File

@@ -1,273 +0,0 @@
.srch #main {
visibility: hidden
}
.srch #bg {
filter: blur(70px) brightness(.5) saturate(1.5)
}
.srch #dim {
opacity: .35
}
.srch #dim2 {
opacity: 0 !important
}
#sr {
position: fixed;
inset: 0;
z-index: 25;
background: linear-gradient(180deg, rgba(0, 0, 0, .2), rgba(0, 0, 0, .6));
overflow-y: auto;
opacity: 0;
pointer-events: none;
transition: opacity .35s;
padding: 6vh var(--pad) 8vh;
scrollbar-width: none
}
#sr.show {
opacity: 1;
pointer-events: auto
}
.stop {
display: flex;
align-items: center;
gap: 18px;
height: clamp(46px, 4.6vw, 76px)
}
.stop svg {
width: clamp(30px, 2.8vw, 46px);
height: clamp(30px, 2.8vw, 46px);
color: rgba(255, 255, 255, .85);
flex: 0 0 auto
}
#stext {
font-size: clamp(28px, 3vw, 52px);
font-weight: 600;
letter-spacing: -.02em;
color: rgba(255, 255, 255, .5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 70vw
}
#stext.q {
color: #fff
}
#stext.q::after {
content: "";
display: inline-block;
width: 3px;
height: .8em;
background: #fff;
margin-left: 5px;
vertical-align: -.08em;
animation: bl 1s steps(1) infinite
}
@keyframes bl {
50% {
opacity: 0
}
}
#kb {
display: flex;
align-items: center;
justify-content: space-between;
gap: clamp(2px, .35vw, 8px);
margin: 1.4vh 0 0 4px;
width: min(100%, 1180px)
}
.k {
flex: 1 1 0;
min-width: 0;
max-width: 46px;
height: clamp(34px, 3vw, 52px);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(16px, 1.6vw, 26px);
font-weight: 500;
color: rgba(255, 255, 255, .88);
transition: background .2s, color .2s, transform .22s cubic-bezier(.22, .9, .3, 1)
}
.k.w {
flex: 0 0 auto;
max-width: none;
font-size: clamp(9px, .8vw, 13px);
font-weight: 700;
padding: 0 clamp(8px, .8vw, 14px);
background: rgba(255, 255, 255, .22);
letter-spacing: .04em;
height: clamp(26px, 2.3vw, 40px)
}
.k.d svg {
width: 1.1em;
height: 1.1em
}
.k.on {
background: #fff;
color: #000;
transform: scale(1.22);
}
.row.ch {
padding: 16px 6px 18px;
min-height: 0;
margin: 0 -6px;
gap: 14px
}
.chip {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
padding: 0 20px;
height: clamp(36px, 3vw, 52px);
border-radius: 12px;
background: rgba(255, 255, 255, .24);
color: #fff;
font-size: clamp(14px, 1.3vw, 22px);
font-weight: 500;
white-space: nowrap;
transition: background .2s, color .2s, transform .22s cubic-bezier(.22, .9, .3, 1)
}
.chip svg {
width: 1.1em;
height: 1.1em
}
.chip.on {
background: #fff;
color: #000;
transform: scale(1.06)
}
.tabs {
display: flex;
justify-content: space-around;
border-top: 1px solid rgba(255, 255, 255, .22);
margin-top: 1vh;
padding-top: 1.6vh
}
.tab {
padding: 8px 24px;
border-radius: 999px;
font-size: clamp(13px, 1.15vw, 19px);
color: rgba(255, 255, 255, .65);
position: relative;
transition: background .2s, color .2s
}
.tab.act {
color: #fff;
font-weight: 600
}
.tab.act::after {
content: "";
position: absolute;
left: 24px;
right: 24px;
bottom: 0;
height: 2px;
background: #fff;
border-radius: 2px
}
.tab.on {
background: rgba(255, 255, 255, .2);
color: #fff
}
.ss {
margin-top: 2.6vh
}
.ss h3 {
font-size: clamp(14px, 1.2vw, 20px);
font-weight: 500;
color: rgba(255, 255, 255, .75)
}
.sc {
flex: 0 0 var(--cw);
width: var(--cw);
max-width: var(--cw);
min-width: 0;
transition: transform .32s cubic-bezier(.22, .9, .3, 1);
}
.sc .po {
width: 100%;
height: calc(var(--cw) * 1.5);
aspect-ratio: 2/3;
border-radius: 14px;
overflow: hidden;
background: #1b1b24;
}
.sc .po img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
filter: brightness(.86);
transition: filter .3s
}
.sc b {
display: block;
margin-top: 10px;
font-size: clamp(13px, 1vw, 17px);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis
}
.sc small {
display: flex;
gap: 8px;
margin-top: 3px;
color: rgba(255, 255, 255, .62);
font-size: clamp(11px, .85vw, 14px);
white-space: nowrap
}
.sc small span+span::before {
content: "\2022";
margin-right: 8px;
opacity: .5
}
.sc.on {
transform: scale(1.05) translateY(-3px)
}
.sc.on img {
filter: none
}

View File

@@ -1,114 +0,0 @@
#side {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 340px;
z-index: 30;
pointer-events: none
}
#side::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(0, 0, 0, .92) 0%, rgba(0, 0, 0, .7) 55%, transparent 100%);
opacity: 0;
transition: opacity .35s
}
#side.open::before {
opacity: 1
}
#side nav {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: auto
}
.ni {
margin-left: 16px;
width: 56px;
height: 56px;
border-radius: 28px;
display: flex;
align-items: center;
gap: 18px;
padding: 0 16px;
color: #fff;
opacity: .45;
overflow: hidden;
white-space: nowrap;
transition: width .32s cubic-bezier(.2, .8, .2, 1), background .25s, color .25s, opacity .25s
}
.ni svg {
width: 24px;
height: 24px;
flex: 0 0 24px
}
.ni span {
font-size: 19px;
font-weight: 600;
opacity: 0;
transition: opacity .25s
}
.ni.act {
opacity: 1
}
#side.open .ni {
width: 260px;
opacity: .85
}
#side.open .ni span {
opacity: 1
}
#side.open .ni.on {
background: #fff;
color: #000;
opacity: 1
}
.brand {
position: absolute;
left: 24px;
top: 5vh;
display: flex;
align-items: center;
gap: 14px;
height: 44px;
color: #fff
}
.brand svg,
.brand img {
width: 40px;
height: 40px;
flex: 0 0 40px;
object-fit: contain
}
.brand span {
font-size: 24px;
font-weight: 700;
letter-spacing: -.03em;
opacity: 0;
transform: translateX(-6px);
transition: opacity .3s, transform .3s
}
#side.open .brand span {
opacity: 1;
transform: none
}

213
css/skeleton.css vendored
View File

@@ -1,213 +0,0 @@
.sb,
.card:not(.lded),
.sc .po:not(.lded),
.ep .et:not(.lded),
.cs.sl .pf,
.trk,
.chip.sl {
position: relative;
overflow: hidden;
background: #15151c
}
.sb::before,
.card:not(.lded)::before,
.sc .po:not(.lded)::before,
.ep .et:not(.lded)::before,
.cs.sl .pf::before,
.trk::before,
.chip.sl::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 25%, rgba(255, 255, 255, .075) 50%, transparent 75%);
transform: translateX(-100%);
animation: shim 1.5s ease-in-out infinite
}
@keyframes shim {
to {
transform: translateX(100%)
}
}
.card img,
.sc .po img,
.ep .et img {
opacity: 0;
transition: opacity .45s ease, filter .38s
}
.card img.ld,
.sc .po img.ld,
.ep .et img.ld {
opacity: 1
}
.sb {
display: block;
border-radius: 8px;
height: 14px
}
.sec>.sb.h2 {
width: 220px;
height: 22px;
margin: 0 var(--pad) 4px
}
.ds>.sb.h3 {
width: 180px;
height: 22px;
margin: 0 6vw 6px
}
.ss>.sb.h3s {
width: 160px;
height: 18px
}
.sc.sl .sb {
width: 80%;
height: 13px;
margin-top: 10px
}
.sc.sl .sb.s {
width: 50%;
height: 11px;
margin-top: 6px
}
.chip.sl {
height: clamp(36px, 3vw, 52px)
}
.trk {
flex: 0 0 clamp(250px, 21vw, 400px);
aspect-ratio: 16/10;
border-radius: 20px
}
.cs.sl {
flex: 0 0 clamp(96px, 7.6vw, 150px)
}
.hskel {
display: none
}
.hc.sl .hskel {
display: block
}
.hc.sl>:not(.hskel) {
display: none
}
.hskel .t,
.dsk .t {
width: min(32vw, 400px);
height: 10vh;
border-radius: 14px;
margin-bottom: 3vh
}
.hskel .m,
.dsk .m {
width: min(22vw, 300px);
height: 18px;
margin-bottom: 2vh
}
.hskel .l,
.dsk .l {
width: min(44vw, 620px);
height: 15px;
margin-bottom: 10px
}
.hskel .l.x,
.dsk .l.x {
width: min(26vw, 360px)
}
.hskel .hb,
.dsk .hb {
margin: 3vh 0
}
.hskel .b,
.dsk .b {
width: clamp(130px, 11vw, 190px);
height: clamp(44px, 3.6vw, 64px);
border-radius: 999px
}
.hskel .b.c,
.dsk .c {
width: clamp(44px, 3.6vw, 64px);
border-radius: 50%
}
.dw {
animation: fin .4s ease
}
@keyframes fin {
from {
opacity: 0
}
}
#pspin {
position: absolute;
left: 50%;
top: 50%;
width: 64px;
height: 64px;
margin: -32px 0 0 -32px;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, .2);
border-top-color: #fff;
animation: spin .8s linear infinite;
opacity: 0;
pointer-events: none;
transition: opacity .2s
}
#pl.buf #pspin {
opacity: 1
}
#pl.buf #pmid {
opacity: 0 !important
}
@keyframes spin {
to {
transform: rotate(360deg)
}
}
.pe .et:not(.lded)::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 25%, rgba(255, 255, 255, .075) 50%, transparent 75%);
transform: translateX(-100%);
animation: shim 1.5s ease-in-out infinite
}
.pe .et:not(.lded) {
background: #15151c
}
.pe .et img {
opacity: 0;
transition: opacity .45s ease
}
.pe .et img.ld {
opacity: 1
}

View File

@@ -1,94 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Vyla</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/sidebar.css">
<link rel="stylesheet" href="css/detail.css">
<link rel="stylesheet" href="css/search.css">
<link rel="stylesheet" href="css/library.css">
<link rel="stylesheet" href="css/player.css">
<link rel="stylesheet" href="css/skeleton.css">
<script src="js/hls.min.js"></script>
</head>
<body>
<div id="bg"></div>
<div id="dim"></div>
<div id="dim2"></div>
<div id="main">
<div class="hero">
<div id="hb"></div>
<div class="hc sl">
<div class="hskel"><i class="sb t"></i><i class="sb m"></i><i class="sb l"></i><i class="sb l"></i><i
class="sb l x"></i>
<div class="hb"><i class="sb b"></i><i class="sb b c"></i></div>
</div>
<div id="hlogo"></div>
<div class="meta" id="hmeta"></div>
<p class="ov1" id="hov"></p>
<div class="hb"><button class="pb f" id="hplay">&#9654; Play</button><button class="ib f"
id="hinfo">&#9432; More Info</button></div>
</div>
</div>
<div id="rows"></div>
<div id="sentinel"></div>
</div>
<div id="sr">
<div class="stop"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg><span id="stext">Search</span></div>
<div id="kb"></div>
<div class="row ch" id="chips"></div>
<div class="tabs" id="tabs"></div>
<div id="sres"></div>
</div>
<div id="lib">
<h1 id="lt"></h1>
<div class="grid" id="lgrid"></div>
</div>
<aside id="side">
<div class="brand"><img id="mark" alt="Vyla"><span>Vyla</span></div>
<nav id="nav"></nav>
</aside>
<div id="ov"></div>
<div id="pl">
<video id="pbg" playsinline preload="auto" muted></video>
<video id="pv" playsinline preload="auto"></video>
<div class="top" id="ptop"></div>
<div class="f" id="pmid"></div>
<div id="pact">
<div class="skip f" id="pskip">Skip Intro</div>
<div class="skip f" id="pnext"></div>
</div>
<div class="bot" id="pbot"></div>
<div id="pep"></div>
<div id="pspin"></div>
<div id="pm"></div>
</div>
<script src="js/vyla-local.js"></script>
<script src="js/native.js"></script>
<script src="js/core.js"></script>
<script src="js/skeleton.js"></script>
<script src="js/focus.js"></script>
<script src="js/library.js"></script>
<script src="js/sidebar.js"></script>
<script src="js/browse.js"></script>
<script src="js/search.js"></script>
<script src="js/detail.js"></script>
<script src="js/player.js"></script>
<script src="js/input.js"></script>
<script src="js/main.js"></script>
</body>
</html>

View File

@@ -1,57 +0,0 @@
function hero(it) { clearTimeout(ht); ht = setTimeout(() => applyHero(it), 130) }
async function applyHero(it) {
if (heroIt === it) return; heroIt = it; $('.hc').classList.remove('sl');
const bp = it.backdrop_path || it.poster_path;
xf(bgb, I + 'w780' + bp); xf(hb, I + 'w1280' + bp);
const t = it.title || it.name;
$('#hlogo').innerHTML = `<h1>${t}</h1>`;
$('#hmeta').innerHTML = `<span>&#9733; ${(it.vote_average || 0).toFixed(1)}</span><span>${yr(it)}</span><span class="b">${it._t === 'movie' ? 'Movie' : 'Series'}</span><span>${gn(it)}</span>`;
$('#hov').textContent = it.overview || '';
const l = await logo(it);
if (heroIt === it && l) $('#hlogo').innerHTML = `<img src="${l}" alt="${t.replace(/"/g, '')}">`
}
function card(it, h = true) {
const c = document.createElement('div'); c.className = 'card f'; c.tabIndex = -1;
c.innerHTML = `<img loading="lazy" src="${I}w342${it.poster_path}" alt="">`;
if (h) c._it = it; c._a = () => openDetail(it); return c
}
function mkRow(i) {
const mv = mode === "movie" || (mode === "discover" && i % 2 === 0), k = mode === "discover" ? i >> 1 : i, list = mv ? MG : TG, g = list[k % list.length], pg = 1 + Math.floor(k / list.length) % 20;
const sec = document.createElement('section'); sec.className = 'sec';
sec.innerHTML = `<h2>${g.name} ${mv ? 'Movies' : 'TV Shows'}</h2><div class="row">${SK.cards(14)}</div>`;
rowsEl.appendChild(sec);
return api(`/discover/${mv ? 'movie' : 'tv'}`, `&with_genres=${g.id}&sort_by=popularity.desc&vote_count.gte=100&include_adult=false&page=${pg}`).then(d => {
const rs = (d.results || []).filter(x => x.poster_path);
if (!rs.length) { sec.remove(); return }
sec.lastChild.innerHTML = ''; rs.forEach(x => { x._t = mv ? 'movie' : 'tv'; sec.lastChild.appendChild(card(x)) })
})
}
async function more(n) {
if (busy) return; busy = true; const g = gen;
const ps = []; for (let j = 0; j < n; j++)ps.push(mkRow(idx++));
await Promise.all(ps); if (g === gen) busy = false
}
async function go(k) {
if (k === 'search') { closeLib(true); openSearch(); return }
closeSearch(true);
if (k === 'wl' || k === 'fav') { openLib(k); return }
closeLib(true);
if (k === mode && rowsEl.children.length) { setAct(k); setFocus(document.querySelector('#rows .card')); return }
mode = k; setAct(k); gen++; busy = false; idx = 0; heroIt = null; lastMain = null; rowsEl.innerHTML = ''; main.scrollTop = 0; $('.hc').classList.add('sl');
await more(6);
const first = document.querySelector('#rows .card');
if (first) { applyHero(first._it); setFocus(first) }
}
main.addEventListener('scroll', () => {
const h = innerHeight;
$('#dim2').style.opacity = Math.min(main.scrollTop / (h * .6), 1) * .55;
if (main.scrollTop + h * 2.5 > main.scrollHeight) more(4)
});
$('#hplay')._a = () => openPlayer(heroIt);
$('#hinfo')._a = () => heroIt && openDetail(heroIt);

View File

@@ -1,37 +0,0 @@
const API_BASE = window.API_BASE || localStorage.getItem('vyla.api') || '';
const K = '3f6703f3e2a0b2b40fe4fbb3e1546244', A = 'https://api.themoviedb.org/3', I = 'https://image.tmdb.org/t/p/';
const $ = s => document.querySelector(s);
const main = $('#main'), rowsEl = $('#rows'), side = $('#side'), ov = $('#ov'), bgb = $('#bg'), hb = $('#hb'), sr = $('#sr'), sres = $('#sres'), chips = $('#chips'), tabs = $('#tabs'), kb = $('#kb'), pl = $('#pl'), vd = $('#pv');
const api = async (p, q = '') => { try { const r = await fetch(`${A}${p}?api_key=${K}&language=en-US${q}`); return r.ok ? await r.json() : {} } catch (e) { return {} } };
const ic = p => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
const P_WL = '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>', P_FV = '<path d="M20.8 5.6a5.5 5.5 0 0 0-7.8 0L12 6.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1 1.1L12 22l7.8-7.5 1-1.1a5.5 5.5 0 0 0 0-7.8z"/>';
const LN = { wl: 'Watch Later', fav: 'Favorites' };
const NAVEL = {}; let navAct = null, mode = '', gen = 0, sgen = 0, st;
let fast = false, G = {}, MG = [], TG = [], cur = null, lastMain = null, heroIt = null, idx = 0, busy = false, ht;
const LC = {};
function xf(box, url, cls = 'xf') {
if (box._u === url) return; box._u = url;
const im = new Image();
im.onload = () => { if (box._u !== url) return; const d = document.createElement('div'); d.className = cls; d.style.backgroundImage = `url(${url})`; box.appendChild(d); requestAnimationFrame(() => requestAnimationFrame(() => d.classList.add('in'))); setTimeout(() => { while (box.children.length > 2) box.firstChild.remove() }, 800) };
im.onerror = () => { if (box._u === url) box._u = null };
im.src = url
}
async function logo(it) {
const k = it._t + it.id; if (k in LC) return LC[k];
const d = await api(`/${it._t}/${it.id}/images`, '&include_image_language=en,null');
const l = (d.logos || []).find(x => x.iso_639_1 === 'en') || (d.logos || [])[0];
return LC[k] = l ? I + 'w500' + l.file_path : null
}
const yr = it => ((it.release_date || it.first_air_date || '') + '').slice(0, 4);
const gn = it => (it.genre_ids || []).map(i => G[i]).filter(Boolean).slice(0, 2).join(', ');
const esc = t => String(t).replace(/</g, '&lt;');
const mkf = (cls, html, fn) => { const e = document.createElement('div'); e.className = cls + ' f'; e.tabIndex = -1; e.innerHTML = html; e._a = fn; return e };
const LOGO = 'assets/favicon.ico', PH = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" rx="11" fill="#2a2a34"/><circle cx="14" cy="14" r="3" fill="#5b5b6b"/><path d="M7 31l9-10 6 6 4-4 7 8z" fill="#5b5b6b"/></svg>');
function toast(m) { const t = document.createElement('div'); t.className = 'toast'; t.textContent = m; (document.fullscreenElement || document.body).appendChild(t); setTimeout(() => t.remove(), 2300) }
const imgDone = e => { const t = e.target; if (t.tagName === 'IMG') { t.classList.add('ld'); t.parentElement && t.parentElement.classList.add('lded') } };
document.addEventListener('load', imgDone, true); document.addEventListener('error', imgDone, true);

View File

@@ -1,66 +0,0 @@
let dgen = 0;
async function openDetail(it) {
const t = it._t;
if (!ov.classList.contains('show')) ov._prev = cur;
ov.classList.add('show'); side.classList.remove('open'); const my = ++dgen; ov.innerHTML = `<div class="pic f" id="dback">${ic('<path d="M19 12H5M12 19l-7-7 7-7"/>')}</div>` + SK.detail(it); $('#dback')._a = closeDetail; ov.scrollTop = 0;
const d = await api(`/${t}/${it.id}`, `&append_to_response=videos,credits,recommendations,images,${t === 'movie' ? 'release_dates' : 'content_ratings'}&include_image_language=en,null`);
if (my !== dgen || !ov.classList.contains('show')) return;
if (!d.id) { closeDetail(); return }
const title = d.title || d.name, year = ((d.release_date || d.first_air_date) || '').slice(0, 4);
const cert = t === 'movie' ? (((d.release_dates || {}).results || []).find(r => r.iso_3166_1 === 'US') || { release_dates: [] }).release_dates.map(x => x.certification).find(Boolean) : (((d.content_ratings || {}).results || []).find(r => r.iso_3166_1 === 'US') || {}).rating;
const rt = t === 'movie' ? (d.runtime ? `${Math.floor(d.runtime / 60)}h ${d.runtime % 60}m` : '') : (d.number_of_seasons ? `${d.number_of_seasons} Season${d.number_of_seasons > 1 ? 's' : ''}` : '');
const lg = ((d.images || {}).logos || []).find(x => x.iso_639_1 === 'en') || ((d.images || {}).logos || [])[0];
const bp = d.backdrop_path || d.poster_path;
ov.innerHTML = `<div class="pic f" id="dback">${ic('<path d="M19 12H5M12 19l-7-7 7-7"/>')}</div><div class="dbl" style="background-image:url(${I}w780${bp})"></div><div class="dw"><div class="dimg" style="background-image:url(${I}w1280${bp})"></div><div class="dc"><div class="dh"><div id="dlogo">${lg ? `<img src="${I}w500${lg.file_path}" alt="">` : `<h1>${title}</h1>`}</div><div class="meta"><span>${(d.vote_average || 0).toFixed(1)}</span><span>${year}</span>${cert ? `<span class="b">${cert}</span>` : ''}<span>${rt}</span><span>${(d.genres || []).slice(0, 3).map(g => g.name).join(', ')}</span></div><div class="hb"><button class="pb f" id="dplay">Play</button><div class="circ f" id="dmenu">&#8942;</div><div class="mx" id="dmx"><div class="circ f" id="dwl"></div><div class="circ f" id="dfv"></div></div></div><p class="otx" id="otx">${d.overview || ''}</p><span class="more f" id="dmore">MORE</span></div><div id="dsecs"></div></div></div>`;
$('#dback')._a = closeDetail; const secs = $('#dsecs');
const rowOf = (name, kids) => { if (!kids.length) return; const s = document.createElement('section'); s.className = 'ds'; s.innerHTML = `<h3>${name}</h3><div class="row"></div>`; s.lastChild.append(...kids); secs.appendChild(s) };
const el = (c, h) => { const e = document.createElement('div'); e.className = c + ' f'; e.tabIndex = -1; e.innerHTML = h; e._a = () => { }; return e };
if (t === 'tv') {
const ss = (d.seasons || []).filter(x => x.season_number > 0 && x.episode_count);
if (ss.length) {
const sec = document.createElement('section'); sec.className = 'ds eps';
sec.innerHTML = '<div class="eh"><h3>Episodes</h3><div class="dd"><div class="ddb f" id="ddb"></div><div class="ddl" id="ddl"></div></div></div><div class="el" id="el"></div>';
secs.appendChild(sec);
const ddb = $('#ddb'), ddl = $('#ddl'), list = $('#el'); let cs = null;
const nm = x => x.name || 'Season ' + x.season_number;
const loadEps = async x => {
cs = x; ddb.innerHTML = `<span>${esc(nm(x))}</span>` + ic('<path d="m6 9 6 6 6-6"/>');
[...ddl.children].forEach((c, i) => c.classList.toggle('cur', ss[i] === x));
list.innerHTML = SK.eps(10);
const r = await api(`/tv/${it.id}/season/${x.season_number}`);
if (cs !== x || !list.isConnected) return;
list.innerHTML = '';
(r.episodes || []).forEach((p, ix) => {
const n = p.episode_number, img = p.still_path ? I + 'w300' + p.still_path : (it.backdrop_path ? I + 'w300' + it.backdrop_path : PH);
const e = el('ep', `<div class="et"><img loading="lazy" src="${img}" alt=""></div><b>${esc(p.name || 'Episode ' + n)}</b><small>${['Episode ' + n, p.runtime ? p.runtime + 'm' : ''].filter(Boolean).join(' \u2022 ')}</small>`);
e.title = p.overview || '';
e._a = () => openPlayer(it, `S${x.season_number} E${n} \u2022 ${p.name || ''}`, { it, seasons: ss, s: x.season_number, eps: r.episodes, i: ix, cache: { [x.season_number]: r.episodes } }); list.appendChild(e)
});
const f0 = list.firstChild; if (f0 && f0._a && $('#dplay')) $('#dplay')._a = () => f0._a();
if (!list.children.length) list.innerHTML = '<p class="none">No episodes listed.</p>'
};
ss.forEach(x => ddl.appendChild(mkf('ddi', `<span>${esc(nm(x))}</span><small>${x.episode_count} eps</small>`, () => { closeDD(); loadEps(x) })));
ddb._a = () => { if (!closeDD()) { ddl.classList.add('open'); setFocus(ddl.children[Math.max(0, ss.indexOf(cs))]) } };
loadEps(ss[0])
}
}
rowOf('Trailers', ((d.videos || {}).results || []).filter(v => v.site === 'YouTube' && /Trailer|Teaser|Clip/.test(v.type)).sort((a, b) => (b.type === 'Trailer') - (a.type === 'Trailer')).slice(0, 8).map(v => el('tr', `<div class="tb" style="background-image:url(https://img.youtube.com/vi/${v.key}/mqdefault.jpg)"></div><div class="th" style="background-image:url(https://img.youtube.com/vi/${v.key}/mqdefault.jpg)"></div><b>${v.name}</b><small>&#9654; ${v.type}</small>`)));
rowOf('Related', ((d.recommendations || {}).results || []).filter(x => x.poster_path).map(x => { x._t = t; return card(x, false) }));
rowOf('Cast', ((d.credits || {}).cast || []).slice(0, 20).map(p => el('cs', `<div class="pf" style="${p.profile_path ? `background-image:url(${I}w185${p.profile_path})` : ''}">${p.profile_path ? '' : p.name[0]}</div><b>${p.name}</b><small>${p.character || ''}</small>`)));
$('#dplay')._a = () => openPlayer(it); const sync = () => [['dwl', 'wl', P_WL], ['dfv', 'fav', P_FV]].forEach(([id, k, p]) => { const e = $('#' + id), v = has(k, it); e.classList.toggle('sv', v); e.dataset.l = (v ? 'Remove from ' : 'Add to ') + LN[k]; e.innerHTML = ic(p) });
sync();
$('#dmenu')._a = () => { const o = $('#dmx').classList.toggle('open'); $('#dmenu').classList.toggle('sv', o) };
$('#dwl')._a = () => { toggleLib('wl', it); sync() }; $('#dfv')._a = () => { toggleLib('fav', it); sync() };
$('#dmore')._a = () => { const o = $('#otx'), m = $('#dmore'); o.classList.toggle('x'); m.textContent = o.classList.contains('x') ? 'LESS' : 'MORE' };
ov.scrollTop = 0;
setFocus($('#dplay'))
}
function closeDetail() {
ov.classList.remove('show');
const p = ov._prev;
setTimeout(() => { if (!ov.classList.contains('show')) ov.innerHTML = '' }, 400);
if (p && document.body.contains(p)) setFocus(p); else if (lib.classList.contains('show')) setFocus($('#lgrid .card') || navAct)
}
function closeDD() { const l = $('#ddl'); if (l && l.classList.contains('open')) { l.classList.remove('open'); setFocus($('#ddb')); return true } return false }

View File

@@ -1,60 +0,0 @@
function setFocus(el) {
if (!el) return;
const od = $('#ddl.open'); if (od && !el.closest('.dd')) od.classList.remove('open');
cur && cur.classList.remove('on'); cur = el; el.classList.add('on');
const inSide = !!el.closest('#side'), inOv = !!el.closest('#ov');
side.classList.toggle('open', inSide && !ov.classList.contains('show'));
if (!inSide && !inOv && !el.closest('#pl')) lastMain = el;
const row = el.closest('.row');
if (row) row.scrollTo({ left: Math.max(0, el.offsetLeft - parseInt(getComputedStyle(row).paddingLeft)), behavior: fast ? 'auto' : 'smooth' });
const menu = el.closest('#pm');
if (menu) {
el.scrollIntoView({ block: 'nearest', behavior: fast ? 'auto' : 'smooth' });
return;
}
const scr = el.closest('#ov,#sr,#lib');
if (scr && el.closest('#kb,#chips,#tabs')) scr.scrollTo({ top: 0, behavior: fast ? 'auto' : 'smooth' });
else if (scr) {
const r = el.getBoundingClientRect();
if (r.bottom > innerHeight - 50) scr.scrollBy({ top: r.bottom - innerHeight + 110, behavior: fast ? 'auto' : 'smooth' });
else if (r.top < 50) scr.scrollBy({ top: r.top - 110, behavior: fast ? 'auto' : 'smooth' });
}
else if (!inSide && !el.closest('#pl')) {
const sec = el.closest('.sec');
main.scrollTo({ top: sec ? Math.max(0, sec.offsetTop - innerHeight * .52) : 0, behavior: fast ? 'auto' : 'smooth' });
}
if (el._it) hero(el._it); if (el._bg) sbgf(el._bg);
}
function nav(d) {
const sc = pl.classList.contains('show') ? (pl.classList.contains('eps') ? '#pep .f' : '#pl .f') : ov.classList.contains('show') ? '#ov .f' : sr.classList.contains('show') ? '#sr .f,#side .f' : lib.classList.contains('show') ? '#lib .f,#side .f' : '#side .f,#main .f';
const c = cur.getBoundingClientRect(), cx = c.left + c.width / 2, cy = c.top + c.height / 2;
const inSide = !!cur.closest('#side');
if (inSide && d === 'r' && lastMain && getComputedStyle(lastMain).visibility !== 'hidden') { setFocus(lastMain); return }
if ((d === 'l' || d === 'r') && !inSide) {
const g = cur.closest('.row,#kb,#tabs');
if (g) {
const L = [...g.children].filter(e => e.classList.contains('f') && e.getBoundingClientRect().width), i = L.indexOf(cur), n = L[i + (d === 'r' ? 1 : -1)];
if (i >= 0) { if (n) setFocus(n); else if (d === 'l' && !cur.closest('#ov,#pl')) setFocus(navAct); return }
}
}
let best = null, bs = 1e9;
document.querySelectorAll(sc).forEach(e => {
if (e === cur) return;
const r = e.getBoundingClientRect(); if (!r.width) return;
const x = r.left + r.width / 2, y = r.top + r.height / 2, dx = x - cx, dy = y - cy;
let p, s;
if (d === 'r') { if (dx < 8) return; p = dx; s = Math.abs(dy) * 2.5 }
else if (d === 'l') { if (dx > -8) return; p = -dx; s = Math.abs(dy) * 2.5 }
else if (d === 'd') { if (dy < 8) return; p = dy; s = Math.abs(dx) * 2 }
else { if (dy > -8) return; p = -dy; s = Math.abs(dx) * 2 }
if (p + s < bs) { bs = p + s; best = e }
});
if (!best) return;
if (best.closest('#side') && !inSide) best = navAct;
setFocus(best)
}
function act(el) { el && el._a && el._a() }

View File

@@ -1,112 +0,0 @@
if (window.tizen && window.tizen.tvinputdevice) {
try {
['MediaPlay', 'MediaPause', 'MediaPlayPause', 'MediaStop', 'MediaFastForward', 'MediaRewind', '10009']
.forEach(k => tizen.tvinputdevice.registerKey(k));
} catch (_) { }
}
const KM = {
37: 'l', 21: 'l',
38: 'u', 19: 'u',
39: 'r', 22: 'r',
40: 'd', 20: 'd',
13: 'e', 23: 'e', 66: 'e',
27: 'b', 8: 'b', 4: 'b', 10009: 'b', 461: 'b'
};
document.addEventListener('keydown', e => {
fast = e.repeat;
document.body.classList.add('kbm');
if (e.keyCode === 85 || e.keyCode === 179 || e.keyCode === 10252 || e.keyCode === 415) {
e.preventDefault();
tgl();
return;
}
if (e.keyCode === 126 || e.keyCode === 250) {
e.preventDefault();
vd.play();
return;
}
if (e.keyCode === 127 || e.keyCode === 413) {
e.preventDefault();
vd.pause();
return;
}
if (e.keyCode === 89 || e.keyCode === 227 || e.keyCode === 412) {
e.preventDefault();
vd.currentTime = Math.max(0, vd.currentTime - 10);
return;
}
if (e.keyCode === 90 || e.keyCode === 228 || e.keyCode === 417) {
e.preventDefault();
vd.currentTime = Math.min(vd.duration || Infinity, vd.currentTime + 10);
return;
}
if (pl.classList.contains('show')) {
showCtl();
if (e.key === ' ' || e.keyCode === 32) { e.preventDefault(); tgl(); return }
if (cur === $('#pseek') && (e.keyCode === 37 || e.keyCode === 39 || e.keyCode === 21 || e.keyCode === 22)) {
e.preventDefault();
vd.currentTime += (e.keyCode === 39 || e.keyCode === 22) ? 10 : -10;
return;
}
if (cur && cur.id === 'vsl' && (e.keyCode === 37 || e.keyCode === 39 || e.keyCode === 21 || e.keyCode === 22)) {
e.preventDefault();
vd.muted = false;
vd.volume = Math.min(1, Math.max(0, vd.volume + ((e.keyCode === 39 || e.keyCode === 22) ? .1 : -.1)));
cur._v();
return;
}
}
if (sr.classList.contains('show') && !ov.classList.contains('show') && !pl.classList.contains('show')) {
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); setQ(sqv + e.key); return }
if (e.keyCode === 8) { e.preventDefault(); setQ(sqv.slice(0, -1)); return }
}
const k = KM[e.keyCode];
if (!k) return;
e.preventDefault();
if (!cur) {
const fallback = document.querySelector('.f');
if (fallback) setFocus(fallback);
return;
}
if (k === 'e') act(cur);
else if (k === 'b') {
if (pl.classList.contains('show')) {
pmK ? closeMenu(1) : pl.classList.contains('eps') ? closeEps(1) : closePlayer();
} else if (ov.classList.contains('show')) {
closeDD() || closeDetail();
} else if (lib.classList.contains('show')) {
closeLib();
} else if (sr.classList.contains('show')) {
closeSearch();
}
} else {
nav(k);
}
});
let mx = -1, my = -1;
document.addEventListener('mousemove', e => {
if (Math.abs(e.clientX - mx) + Math.abs(e.clientY - my) < 4) return;
mx = e.clientX; my = e.clientY; document.body.classList.remove('kbm');
if (pl.classList.contains('show')) { showCtl(); const g = e.target.closest('.f'); if (g && g !== cur && pl.contains(g)) setFocus(g); return }
const f = e.target.closest('.f');
if (f && f !== cur && (!ov.classList.contains('show') || ov.contains(f))) setFocus(f)
});
document.addEventListener('click', e => {
const f = e.target.closest('.f'); if (f) act(f)
});
ov.addEventListener('wheel', () => { }, { passive: true });
window.vylaBack = () => {
const open = [pl, ov, lib, sr].some(e => e.classList.contains('show'));
if (open) document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 4, key: 'GoBack', bubbles: true, cancelable: true }));
return open;
};

View File

@@ -1,27 +0,0 @@
let LIB = { wl: [], fav: [] }, libK = null; const lib = $('#lib');
try { Object.assign(LIB, JSON.parse(localStorage.getItem('vyla.lib') || '{}')) } catch (e) { }
const saveLib = () => { try { localStorage.setItem('vyla.lib', JSON.stringify(LIB)) } catch (e) { } };
const slim = it => ({ id: it.id, _t: it._t || it.media_type, title: it.title, name: it.name, poster_path: it.poster_path, backdrop_path: it.backdrop_path, release_date: it.release_date, first_air_date: it.first_air_date, vote_average: it.vote_average, overview: it.overview });
const has = (k, it) => LIB[k].some(x => x.id === it.id && x._t === it._t);
function toggleLib(k, it) {
const i = LIB[k].findIndex(x => x.id === it.id && x._t === it._t), had = i >= 0;
if (had) LIB[k].splice(i, 1); else LIB[k].unshift(slim(it));
saveLib(); syncNav(); toast((had ? 'Removed from ' : 'Added to ') + LN[k]);
if (lib.classList.contains('show')) drawLib()
}
function drawLib() {
const k = libK, a = LIB[k], g = $('#lgrid');
$('#lt').innerHTML = `${LN[k]}<small>${a.length} title${a.length === 1 ? '' : 's'}</small>`;
g.innerHTML = '';
if (!a.length) { g.innerHTML = '<p class="none">Nothing here yet. Open a title, press the \u22EE button and add it.</p>'; return }
a.forEach(x => { const c = card(x, false); c._bg = x; g.appendChild(c) })
}
function openLib(k) {
libK = k; setAct(k); document.body.classList.add('lib'); lib.classList.add('show'); side.classList.remove('open'); lib.scrollTop = 0;
drawLib(); syncNav(); setFocus($('#lgrid .card') || NAVEL[k])
}
function closeLib(silent) {
if (!lib.classList.contains('show')) return;
lib.classList.remove('show'); document.body.classList.remove('lib'); libK = null; heroIt = null; bgb._u = null; syncNav();
if (!silent) { setAct(mode); setFocus($('#rows .card')) }
}

View File

@@ -1,8 +0,0 @@
(async () => {
rowsEl.innerHTML = SK.rows(4);
const [m, t] = await Promise.all([api('/genre/movie/list'), api('/genre/tv/list')]);
MG = m.genres || []; TG = t.genres || [];
[...MG, ...TG].forEach(g => G[g.id] = g.name);
if (!MG.length || !TG.length) { rowsEl.innerHTML = '<p style="padding:40vh var(--pad)">Could not reach TMDB.</p>'; return }
await go('discover');
})();

View File

@@ -1,112 +0,0 @@
(() => {
const cap = window.Capacitor || {};
const N = (cap.Plugins && cap.Plugins.VylaNative) || cap.registerPlugin('VylaNative');
window.API_BASE = 'http://127.0.0.1:38417';
const webFetch = window.fetch.bind(window);
const dec = new TextDecoder('utf-8');
const bytesOf = s => { const b = atob(s), u = new Uint8Array(b.length); for (let i = 0; i < b.length; i++) u[i] = b.charCodeAt(i); return u };
const abortErr = () => new DOMException('The operation was aborted', 'AbortError');
const wrap = r => {
const bytes = bytesOf(r.body || ''), h = r.headers || {};
const text = async () => dec.decode(bytes);
return {
ok: r.status >= 200 && r.status < 300,
status: r.status,
statusText: '',
url: r.url,
headers: { get: k => { const v = h[String(k).toLowerCase()]; return v === undefined ? null : v }, has: k => String(k).toLowerCase() in h },
body: { getReader: () => { let d = false; return { read: async () => d ? { done: true, value: undefined } : (d = true, { done: false, value: bytes }) } }, cancel: async () => { } },
text,
json: async () => JSON.parse(await text()),
arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
};
};
window.fetch = (input, init = {}) => {
const isReq = typeof input !== 'string';
const url = (isReq ? (input && input.url) : input) || '';
if (!/^https?:\/\//i.test(url) || url.startsWith(window.API_BASE) || /^https:\/\/(api|image)\.themoviedb\.org|^https:\/\/image\.tmdb\.org/i.test(url)) return webFetch(input, init);
const hd = {};
new Headers(init.headers || (isReq ? input.headers : undefined)).forEach((v, k) => { hd[k] = v });
const sig = init.signal;
if (sig && sig.aborted) return Promise.reject(abortErr());
const call = N.fetch({
url,
method: String(init.method || (isReq ? input.method : 'GET')).toUpperCase(),
headers: hd,
body: init.body == null ? undefined : String(init.body),
timeout: init.timeout || 120000,
redirect: init.redirect || 'follow',
maxBytes: init.maxBytes || 0
}).then(wrap);
if (!sig) return call;
return Promise.race([call, new Promise((_, rej) => sig.addEventListener('abort', () => rej(abortErr()), { once: true }))]);
};
let booted = false;
const boot = () => { if (!booted) { VylaLocal.init(K); booted = true } };
const opt = v => v === undefined || v === null || v === '' || v === 'null' || v === 'undefined' ? undefined : String(v);
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
const OKS = [200, 206, 304];
const SEG = { recap: 'Skip Recap', intro: 'Skip Intro', preview: 'Skip Preview', credits: 'Skip Credits' };
const probeGet = (u, h, max) => fetch(u, { headers: { 'User-Agent': UA, Accept: '*/*', 'Accept-Language': 'en-US,en;q=0.9', ...h, Range: 'bytes=0-1024' }, timeout: 6000, maxBytes: max });
const playable = async (url, h, depth = 0) => {
try {
const r = await probeGet(url, h, 1048576);
if (!OKS.includes(r.status)) return false;
const ct = (r.headers.get('content-type') || '').toLowerCase();
if (!(url.includes('.m3u8') || ct.includes('mpegurl') || ct.includes('m3u8'))) return true;
if (depth > 4) return false;
const line = (await r.text()).split('\n').map(x => x.trim()).find(x => x && x[0] !== '#');
if (!line) return false;
const base = url.slice(0, url.lastIndexOf('/') + 1), origin = new URL(url).origin;
const t = line.startsWith('http') ? line : line.startsWith('/') ? origin + line : base + line;
if (t.includes('m3u8')) return playable(t, h, depth + 1);
return OKS.includes((await probeGet(t, h, 1024)).status);
} catch (_) { return false }
};
window.Vyla = {
sources() {
boot();
return VylaLocal.sources().map(c => ({ key: c.key, label: c.label }));
},
async stream(provider, tmdbId, season, episode) {
boot();
try {
const d = await VylaLocal.stream(String(provider), String(tmdbId), opt(season), opt(episode));
if (!d) return { success: false };
const h = d.headers || {};
if (Array.isArray(d.allUrls) && d.allUrls.length) {
const ok = await Promise.all(d.allUrls.map(async u => (u && u.url && await playable(u.url, h)) ? u : null));
d.allUrls = ok.filter(Boolean);
if (d.allUrls.length) { if (!d.url || !d.allUrls.some(u => u.url === d.url)) d.url = d.allUrls[0].url }
else d.url = null;
} else if (d.url && !(await playable(d.url, h))) d.url = null;
return d.url ? { success: true, provider, stream: d } : { success: false };
} catch (_) { return { success: false } }
},
async subtitles(id, season, episode) {
boot();
try { return (await VylaLocal.subtitles(String(id), opt(season), opt(episode))) || [] } catch (_) { return [] }
},
async timestamps(id, season, episode, durationSec) {
let u = `https://api.theintrodb.org/v3/media?tmdb_id=${id}`;
if (season) u += `&season=${season}`;
if (episode) u += `&episode=${episode}`;
if (durationSec) u += `&duration_ms=${Math.round(durationSec * 1000)}`;
try {
const r = await fetch(u, { timeout: 8000 });
if (!r.ok) return [];
const d = await r.json(), out = [];
Object.keys(SEG).forEach(t => (Array.isArray(d[t]) ? d[t] : []).forEach(x => out.push({ type: t, label: SEG[t], start: x.start_ms != null ? x.start_ms / 1000 : 0, end: x.end_ms != null ? x.end_ms / 1000 : null })));
return out;
} catch (_) { return [] }
}
};
})();

View File

@@ -1,794 +0,0 @@
let currentHls = null;
let currentBgHls = null;
let currentSources = [];
let resolvedStreams = [];
let selectedSourceKey = null;
let selectedStreamUrl = null;
let expandedSources = new Set();
let currentQualityLevels = [];
let selectedQualityIndex = -1;
let activeSubtitles = [];
let mediaSegments = [];
let activeSegment = null;
let plPrev = null;
let plT = null;
let plItem = null;
let pmK = null;
let plCtx = null;
let viewS = null;
let drag = 0;
let discoverySession = 0;
let userHasManuallySelectedSource = false;
let subSettings = {
size: 24,
color: '#ffffff',
bg: 'rgba(0,0,0,0.75)',
offset: 0
};
const pi = (p, f) => `<svg viewBox="0 0 24 24" fill="${f ? 'currentColor' : 'none'}" stroke="${f ? 'none' : 'currentColor'}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
const SPK = '<path d="M4 9v6h4l5 4V5L8 9z"/>';
const PI = {
x: pi('<path d="M5 5l14 14M19 5 5 19"/>'),
play: pi('<path d="M7 4.5v15L19.5 12z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>', 1),
pause: pi('<rect x="6" y="4.5" width="4" height="15" rx="1.2"/><rect x="14" y="4.5" width="4" height="15" rx="1.2"/>', 1),
cc: pi('<rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10.5 10a2 2 0 1 0 0 4M17 10a2 2 0 1 0 0 4"/>'),
vol: pi(SPK + '<path d="M16.5 8.5a5 5 0 0 1 0 7M19 6a9 9 0 0 1 0 12"/>'),
mute: pi(SPK + '<path d="m17 9.5 4 5m0-5-4 5"/>'),
qlt: pi('<path d="M4 6h16M4 12h16M4 18h7"/>'),
set: pi('<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>'),
more: pi('<path d="M5 12h.01M12 12h.01M19 12h.01" stroke-width="3.4"/>'),
fs: pi('<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>'),
fx: pi('<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/>'),
eps: pi('<rect x="3" y="4" width="7" height="6" rx="1.5"/><rect x="14" y="4" width="7" height="6" rx="1.5"/><rect x="3" y="14" width="7" height="6" rx="1.5"/><rect x="14" y="14" width="7" height="6" rx="1.5"/>'),
chk: pi('<path d="m5 12.5 4.5 4.5L19 7"/>')
};
$('#ptop').innerHTML = `<div class="pic f" id="pback">${PI.x}</div><img class="av" id="pav" alt=""><div class="pt"><b id="pt"></b><small id="ps"></small></div><div class="pr"><div class="pic f" id="pqlt" title="Quality">${PI.qlt}</div><div class="pic f" id="pcc">${PI.cc}</div><div class="pic f" id="pvol">${PI.vol}</div><div class="pic f" id="pset">${PI.set}</div><div class="pic f" id="pmore">${PI.more}</div></div>`;
$('#pbot').innerHTML = `<span class="bt" id="pcur">0:00</span><div class="sk f" id="pseek"><u id="pbuf"></u><i id="pfill"></i><b id="phd"></b></div><span class="bt" id="pdur">0:00</span><div class="pic f" id="peps">${PI.eps}</div><div class="pic f" id="pfs">${PI.fs}</div>`;
$('#pmid').innerHTML = PI.play;
const pm = $('#pm');
const sk = $('#pseek');
const pbg = $('#pbg');
const fmt = t => {
t = Math.max(0, Math.floor(t || 0));
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const c = t % 60;
return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(c).padStart(2, '0');
};
const tgl = () => vd.paused ? vd.play() : vd.pause();
const pico = () => {
$('#pmid').innerHTML = vd.paused ? PI.play : PI.pause;
};
function showCtl() {
pl.classList.add('ui');
clearTimeout(plT);
plT = setTimeout(() => {
if (!vd.paused && !pmK && !pl.classList.contains('eps')) {
pl.classList.remove('ui');
}
}, 3800);
}
function closeMenu(rf) {
if (!pmK) return;
const t = {
qlt: '#pqlt',
cc: '#pcc',
vol: '#pvol',
set: '#pset',
more: '#pmore'
}[pmK];
pmK = null;
pm.classList.remove('open');
pm.innerHTML = '';
if (rf) setFocus($(t));
}
function updateSubStyles() {
vd.classList.add('sub-active');
vd.style.setProperty('--sub-size', `${subSettings.size}px`);
vd.style.setProperty('--sub-color', subSettings.color);
vd.style.setProperty('--sub-bg', subSettings.bg);
Array.from(vd.textTracks).forEach(track => {
if (track.cues) {
for (let i = 0; i < track.cues.length; i++) {
const cue = track.cues[i];
if (cue && !cue._initialOffsetApplied) {
cue.startTime += subSettings.offset;
cue.endTime += subSettings.offset;
}
}
}
});
}
function applySubOffset(delta) {
subSettings.offset += delta;
Array.from(vd.textTracks).forEach(track => {
if (track.cues) {
for (let i = 0; i < track.cues.length; i++) {
const cue = track.cues[i];
if (cue) {
cue.startTime += delta;
cue.endTime += delta;
}
}
}
});
}
function buildProxyUrl(rawUrl, headersObj = {}) {
return `${API_BASE}/api/proxy?url=${encodeURIComponent(rawUrl)}&headers=${encodeURIComponent(JSON.stringify(headersObj))}`;
}
async function fetchSources() {
currentSources = Vyla.sources();
}
async function startConcurrentDiscovery(it, s = null, e = null) {
const session = ++discoverySession;
pl.classList.add('buf');
resolvedStreams = [];
selectedStreamUrl = null;
let foundFirstPlayable = false;
if (!currentSources.length) {
await fetchSources();
}
if (session !== discoverySession) return;
const sourcePromises = currentSources.map(async (src) => {
try {
const data = await Vyla.stream(src.key, it.id, s, e);
if (session !== discoverySession) return;
if (data.success && data.stream) {
const streamData = data.stream;
const headers = streamData.headers || {};
let playable = [];
if (Array.isArray(streamData.allUrls) && streamData.allUrls.length > 0) {
playable = streamData.allUrls;
} else if (streamData.url) {
playable = [{ url: streamData.url, quality: streamData.quality || 'Auto', isM3U8: streamData.isM3U8 }];
}
if (playable.length > 0) {
const resolvedEntry = {
provider: src.key,
label: src.label || src.key,
headers,
primaryUrl: playable[0].url,
allUrls: playable
};
resolvedStreams.push(resolvedEntry);
if (pmK === 'set') {
renderSourcesMenu();
}
if (!foundFirstPlayable && !userHasManuallySelectedSource) {
foundFirstPlayable = true;
selectedSourceKey = src.key;
playSpecificStream(resolvedEntry.primaryUrl, resolvedEntry.headers);
} else if (userHasManuallySelectedSource && selectedSourceKey === src.key && !selectedStreamUrl) {
playSpecificStream(resolvedEntry.primaryUrl, resolvedEntry.headers);
}
}
}
} catch (_) { }
});
Promise.allSettled(sourcePromises).then(() => {
if (session !== discoverySession) return;
if (!resolvedStreams.length) {
pl.classList.remove('buf');
toast('No playable sources available.');
}
});
loadSubtitles(it.id, s, e);
loadTimestamps(it.id, s, e);
}
function playSpecificStream(streamUrl, headers = {}) {
pl.classList.add('buf');
selectedStreamUrl = streamUrl;
if (currentHls) {
currentHls.destroy();
currentHls = null;
}
if (currentBgHls) {
currentBgHls.destroy();
currentBgHls = null;
}
const finalUrl = buildProxyUrl(streamUrl, headers);
if (streamUrl.includes('.m3u8')) {
if (Hls.isSupported()) {
currentHls = new Hls({
xhrSetup: function (xhr) {
xhr.withCredentials = false;
}
});
currentHls.loadSource(finalUrl);
currentHls.attachMedia(vd);
currentHls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
currentQualityLevels = data.levels || [];
vd.play().catch(() => { });
});
currentHls.on(Hls.Events.LEVEL_SWITCHED, (_, data) => {
selectedQualityIndex = data.level;
});
if (pbg) {
currentBgHls = new Hls({
xhrSetup: function (xhr) {
xhr.withCredentials = false;
}
});
currentBgHls.loadSource(finalUrl);
currentBgHls.attachMedia(pbg);
currentBgHls.on(Hls.Events.MANIFEST_PARSED, () => {
pbg.muted = true;
if (!vd.paused) pbg.play().catch(() => { });
});
}
} else if (vd.canPlayType('application/vnd.apple.mpegurl')) {
vd.src = finalUrl;
vd.play().catch(() => { });
if (pbg) {
pbg.src = finalUrl;
pbg.muted = true;
pbg.play().catch(() => { });
}
}
} else {
currentQualityLevels = [];
vd.src = finalUrl;
vd.play().catch(() => { });
if (pbg) {
pbg.src = finalUrl;
pbg.muted = true;
pbg.play().catch(() => { });
}
}
}
function setPlaybackQuality(levelIndex) {
selectedQualityIndex = levelIndex;
if (currentHls) {
currentHls.currentLevel = levelIndex;
}
}
async function loadSubtitles(id, s, e) {
Array.from(vd.querySelectorAll('track')).forEach(t => t.remove());
activeSubtitles = [];
try {
activeSubtitles = await Vyla.subtitles(id, s, e);
activeSubtitles.forEach((sub, idx) => {
const track = document.createElement('track');
track.kind = 'subtitles';
track.label = `${sub.label} (${sub.source || 'mirror'})`;
track.srclang = sub.label.slice(0, 2).toLowerCase();
track.src = buildProxyUrl(sub.file);
track.default = idx === 0;
vd.appendChild(track);
track.track.mode = 'disabled';
});
updateSubStyles();
} catch (_) { }
}
async function loadTimestamps(tmdbId, season = null, episode = null, durationSec = null) {
mediaSegments = [];
activeSegment = null;
$('#pskip').classList.remove('v');
try {
let q = `${API_BASE}/api/timestamps?tmdb_id=${tmdbId}`;
if (season) q += `&season=${season}`;
if (episode) q += `&episode=${episode}`;
if (durationSec) q += `&duration_ms=${Math.round(durationSec * 1000)}`;
const res = await fetch(q);
const data = await res.json();
mediaSegments = data.segments || [];
} catch (_) {
mediaSegments = [];
}
}
function renderSourcesMenu() {
let container = $('#pm-src-list');
if (!container) return;
container.innerHTML = '';
if (!resolvedStreams.length) {
container.innerHTML = '<div style="padding:10px 14px;color:rgba(255,255,255,0.4);font-size:13px;">Probing providers...</div>';
return;
}
resolvedStreams.forEach(s => {
const grp = document.createElement('div');
grp.className = 'src-group';
const hdr = document.createElement('div');
hdr.className = 'src-header f' + (s.provider === selectedSourceKey ? ' on' : '');
const hasMulti = s.allUrls && s.allUrls.length > 1;
const countLabel = hasMulti ? ` (${s.allUrls.length})` : '';
const expandIcon = hasMulti ? `<span class="src-expand-btn">${expandedSources.has(s.provider) ? '▲' : '▼'}</span>` : '';
hdr.innerHTML = `<span>${s.label}${countLabel}</span><div>${expandIcon}${s.provider === selectedSourceKey ? PI.chk : ''}</div>`;
hdr._a = () => {
userHasManuallySelectedSource = true;
if (hasMulti && s.provider === selectedSourceKey) {
if (expandedSources.has(s.provider)) expandedSources.delete(s.provider);
else expandedSources.add(s.provider);
renderSourcesMenu();
} else {
selectedSourceKey = s.provider;
playSpecificStream(s.primaryUrl, s.headers);
renderSourcesMenu();
}
};
grp.appendChild(hdr);
if (hasMulti) {
const sub = document.createElement('div');
sub.className = 'src-sublist' + (expandedSources.has(s.provider) ? ' open' : '');
s.allUrls.forEach((u, i) => {
const item = document.createElement('div');
item.className = 'src-subitem f' + (u.url === selectedStreamUrl ? ' on' : '');
item.innerHTML = `<span>${u.quality || 'Stream ' + (i + 1)}</span>${u.url === selectedStreamUrl ? PI.chk : ''}`;
item._a = () => {
userHasManuallySelectedSource = true;
selectedSourceKey = s.provider;
playSpecificStream(u.url, s.headers);
renderSourcesMenu();
};
sub.appendChild(item);
});
grp.appendChild(sub);
}
container.appendChild(grp);
});
}
function openMenu(k) {
if (pmK === k) {
closeMenu(1);
return;
}
closeEps();
pm.innerHTML = '';
pmK = k;
pm.classList.add('open');
let first = null;
const h = t => {
const e = document.createElement('h4');
e.textContent = t;
pm.appendChild(e);
};
const r = (l, on, fn) => {
const e = mkf('pi', `<span>${l}</span>${on ? PI.chk : ''}`, () => {
fn();
closeMenu(1);
});
pm.appendChild(e);
if (on && !first) first = e;
return e;
};
if (k === 'qlt') {
h('Stream Quality');
if (currentHls && currentQualityLevels.length > 0) {
r('Auto', selectedQualityIndex === -1, () => setPlaybackQuality(-1));
currentQualityLevels.forEach((lvl, idx) => {
const label = lvl.height ? `${lvl.height}p` : `${Math.round(lvl.bitrate / 1000)} kbps`;
r(label, selectedQualityIndex === idx, () => setPlaybackQuality(idx));
});
} else {
const activeProv = resolvedStreams.find(s => s.provider === selectedSourceKey);
if (activeProv && activeProv.allUrls.length > 1) {
activeProv.allUrls.forEach((u, idx) => {
const label = u.quality || `Stream ${idx + 1}`;
r(label, u.url === selectedStreamUrl, () => {
userHasManuallySelectedSource = true;
playSpecificStream(u.url, activeProv.headers);
});
});
} else {
r('Auto', true, () => { });
}
}
} else if (k === 'cc') {
h('Subtitles');
const T = [...vd.textTracks];
r('Off', !T.some(t => t.mode === 'showing'), () => {
T.forEach(t => t.mode = 'disabled');
});
T.forEach(t => r(t.label, t.mode === 'showing', () => {
T.forEach(u => u.mode = u === t ? 'showing' : 'disabled');
updateSubStyles();
}));
h('Subtitle Size');
[18, 22, 26, 32].forEach(sz => {
r(`${sz}px`, subSettings.size === sz, () => {
subSettings.size = sz;
updateSubStyles();
});
});
h('Subtitle Color');
[['White', '#ffffff'], ['Yellow', '#ffd700'], ['Cyan', '#00ffff']].forEach(([col, code]) => {
r(col, subSettings.color === code, () => {
subSettings.color = code;
updateSubStyles();
});
});
h('Subtitle Sync');
const syncRow = document.createElement('div');
syncRow.className = 'sub-ctrl-row';
syncRow.innerHTML = `<span>Timing Offset</span><div class="sub-btn-group"><button id="sub-min">-0.5s</button><button id="sub-plus">+0.5s</button></div>`;
pm.appendChild(syncRow);
$('#sub-min').onclick = (ev) => {
ev.stopPropagation();
applySubOffset(-0.5);
};
$('#sub-plus').onclick = (ev) => {
ev.stopPropagation();
applySubOffset(0.5);
};
} else if (k === 'vol') {
h('Volume');
const w = document.createElement('div');
w.className = 'vrow';
w.innerHTML = '<div class="pic f" id="vm"></div><div class="vs f" id="vsl"><i></i></div><span id="vpc"></span>';
pm.appendChild(w);
const vm = $('#vm');
const vs = $('#vsl');
const vu = () => {
const v = vd.muted ? 0 : vd.volume;
vs.firstChild.style.width = v * 100 + '%';
$('#vpc').textContent = Math.round(v * 100);
vm.innerHTML = $('#pvol').innerHTML = v ? PI.vol : PI.mute;
};
vm._a = () => {
vd.muted = !vd.muted;
vu();
};
vs._a = () => { };
vs._v = vu;
vs.addEventListener('click', e => {
const b = vs.getBoundingClientRect();
vd.muted = false;
vd.volume = Math.min(1, Math.max(0, (e.clientX - b.left) / b.width));
vu();
});
vu();
first = vs;
} else if (k === 'set') {
h('Available Sources');
const srcContainer = document.createElement('div');
srcContainer.id = 'pm-src-list';
pm.appendChild(srcContainer);
renderSourcesMenu();
h('Playback Speed');
[.5, 1, 1.25, 1.5, 2].forEach(v => r(v === 1 ? 'Normal' : v + '\u00d7', vd.playbackRate === v, () => vd.playbackRate = v));
h('Aspect Ratio');
r('Fit Screen', !pl.classList.contains('cover'), () => {
pl.classList.remove('cover');
if (pbg) {
pbg.currentTime = vd.currentTime;
if (!vd.paused) pbg.play().catch(() => { });
}
});
r('Fill Screen', pl.classList.contains('cover'), () => {
pl.classList.add('cover');
if (pbg) pbg.pause();
});
} else {
h('Library');
r('Watch Later', has('wl', plItem), () => toggleLib('wl', plItem));
r('Favorites', has('fav', plItem), () => toggleLib('fav', plItem));
}
showCtl();
setFocus(first || pm.querySelector('.f'));
}
async function openPlayer(it, ep, ctx) {
if (!it) return;
plPrev = cur;
plItem = it;
plCtx = ctx || null;
userHasManuallySelectedSource = false;
$('#peps').style.display = plCtx ? '' : 'none';
pl.classList.remove('eps');
$('#pnext').classList.remove('v');
$('#pt').textContent = it.title || it.name || '';
$('#ps').textContent = ep || [yr(it), it._t === 'tv' ? 'Series' : 'Movie'].filter(Boolean).join(' \u2022 ');
$('#pav').src = it.poster_path ? I + 'w185' + it.poster_path : PH;
vd.poster = it.backdrop_path ? I + 'w1280' + it.backdrop_path : '';
pl.classList.add('show');
showCtl();
pico();
setFocus($('#pmid'));
if (plCtx) {
const epData = plCtx.eps[plCtx.i];
startConcurrentDiscovery(it, plCtx.s, epData.episode_number);
} else {
startConcurrentDiscovery(it);
}
}
function closePlayer() {
closeMenu();
closeEps();
if (currentHls) {
currentHls.destroy();
currentHls = null;
}
if (currentBgHls) {
currentBgHls.destroy();
currentBgHls = null;
}
plCtx = null;
vd.pause();
vd.removeAttribute('src');
vd.load();
if (pbg) {
pbg.pause();
pbg.removeAttribute('src');
pbg.load();
}
if (document.fullscreenElement) document.exitFullscreen();
pl.classList.remove('show', 'ui');
if (plPrev && document.body.contains(plPrev)) setFocus(plPrev);
}
$('#pback')._a = closePlayer;
$('#pmid')._a = tgl;
$('#pskip')._a = () => {
if (activeSegment) {
const target = activeSegment.end != null ? activeSegment.end + 0.5 : (vd.duration || vd.currentTime + 30);
vd.currentTime = target;
$('#pskip').classList.remove('v');
activeSegment = null;
} else {
vd.currentTime += 85;
}
};
$('#pqlt')._a = () => openMenu('qlt');
$('#pcc')._a = () => openMenu('cc');
$('#pvol')._a = () => openMenu('vol');
$('#pset')._a = () => openMenu('set');
$('#pmore')._a = () => openMenu('more');
$('#pfs')._a = () => document.fullscreenElement ? document.exitFullscreen() : pl.requestFullscreen && pl.requestFullscreen();
document.addEventListener('fullscreenchange', () => {
$('#pfs').innerHTML = document.fullscreenElement ? PI.fx : PI.fs;
});
const sp = e => {
const b = sk.getBoundingClientRect();
vd.currentTime = Math.min(1, Math.max(0, (e.clientX - b.left) / b.width)) * (vd.duration || 0);
};
sk.addEventListener('pointerdown', e => {
drag = 1;
sk._m = 1;
sk.setPointerCapture(e.pointerId);
sp(e);
});
sk.addEventListener('pointermove', e => {
if (drag) sp(e);
});
sk.addEventListener('pointerup', () => drag = 0);
sk._a = () => {
if (sk._m) {
sk._m = 0;
return;
}
tgl();
};
pl.querySelectorAll('.f').forEach(e => {
if (!e._a) e._a = () => { };
});
vd.addEventListener('click', () => {
if (pmK) closeMenu();
else if (pl.classList.contains('eps')) closeEps();
else tgl();
showCtl();
});
vd.addEventListener('play', () => {
if (!pl.classList.contains('cover') && pbg) {
pbg.muted = true;
pbg.currentTime = vd.currentTime;
pbg.play().catch(() => { });
}
});
vd.addEventListener('pause', () => {
if (pbg) pbg.pause();
});
vd.addEventListener('seeking', () => {
if (pbg) pbg.currentTime = vd.currentTime;
});
vd.addEventListener('seeked', () => {
if (pbg) pbg.currentTime = vd.currentTime;
});
vd.addEventListener('ratechange', () => {
if (pbg) pbg.playbackRate = vd.playbackRate;
});
vd.addEventListener('durationchange', () => {
$('#pdur').textContent = fmt(vd.duration);
if (vd.duration && plItem) {
const c = plCtx;
loadTimestamps(plItem.id, c ? c.s : null, c ? c.eps[c.i].episode_number : null, vd.duration);
}
});
vd.addEventListener('timeupdate', () => {
const d = vd.duration || 0;
const t = vd.currentTime;
const p = d ? (t / d) * 100 : 0;
$('#pfill').style.width = p + '%';
$('#phd').style.left = p + '%';
$('#pcur').textContent = fmt(t);
if (d && vd.buffered.length) {
$('#pbuf').style.width = (vd.buffered.end(vd.buffered.length - 1) / d * 100) + '%';
}
if (!pl.classList.contains('cover') && pbg && Math.abs(pbg.currentTime - t) > 0.35) {
pbg.currentTime = t;
}
const currentSeg = mediaSegments.find(seg => {
const afterStart = t >= seg.start;
const beforeEnd = seg.end == null ? (d ? t < d - 5 : true) : t < seg.end;
return afterStart && beforeEnd;
});
if (currentSeg) {
activeSegment = currentSeg;
$('#pskip').textContent = currentSeg.label;
$('#pskip').classList.add('v');
} else {
activeSegment = null;
$('#pskip').classList.remove('v');
}
});
['play', 'pause', 'ended'].forEach(n => vd.addEventListener(n, pico));
['loadstart', 'waiting', 'seeking'].forEach(n => vd.addEventListener(n, () => pl.classList.add('buf')));
['playing', 'canplay', 'seeked', 'error'].forEach(n => vd.addEventListener(n, () => pl.classList.remove('buf')));
const hasNext = () => !!plCtx && (plCtx.i + 1 < plCtx.eps.length || plCtx.seasons.some(x => x.season_number > plCtx.s));
const getSeason = async s => plCtx.cache[s] || (plCtx.cache[s] = (await api(`/tv/${plCtx.it.id}/season/${s}`)).episodes || []);
async function playEp(s, i) {
const c = plCtx;
if (!c) return;
const eps = await getSeason(s);
const p = eps[i];
if (!p || plCtx !== c) return;
c.s = s;
c.i = i;
c.eps = eps;
$('#ps').textContent = `S${s} E${p.episode_number} \u2022 ${p.name || ''}`;
$('#pnext').classList.remove('v');
closeEps();
showCtl();
setFocus($('#pmid'));
userHasManuallySelectedSource = false;
startConcurrentDiscovery(c.it, s, p.episode_number);
}
function nextEp() {
const c = plCtx;
if (!hasNext()) return;
if (c.i + 1 < c.eps.length) {
playEp(c.s, c.i + 1);
} else {
playEp(c.seasons.find(x => x.season_number > c.s).season_number, 0);
}
}
function closeEps(rf) {
if (!pl.classList.contains('eps')) return;
pl.classList.remove('eps');
if (rf) setFocus($('#peps'));
}
function openEps() {
const c = plCtx;
if (!c) return;
closeMenu();
$('#peT').innerHTML = `Episodes<small>${esc(c.it.title || c.it.name || '')}</small>`;
const sr = $('#pseas');
sr.innerHTML = '';
c.seasons.forEach(x => {
sr.appendChild(mkf('chip', esc(x.name || 'Season ' + x.season_number), () => showSeason(x.season_number)));
});
pl.classList.add('eps');
showCtl();
showSeason(c.s, true);
}
async function showSeason(sn, focus) {
const c = plCtx;
const car = $('#pcar');
if (!c) return;
viewS = sn;
[...$('#pseas').children].forEach((e, i) => e.classList.toggle('act', c.seasons[i].season_number === sn));
car.innerHTML = SK.pep(8);
const eps = await getSeason(sn);
if (viewS !== sn || plCtx !== c) return;
car.innerHTML = '';
eps.forEach((p, i) => {
const n = p.episode_number;
const cur = c.s === sn && c.i === i;
const img = p.still_path ? I + 'w300' + p.still_path : (c.it.backdrop_path ? I + 'w300' + c.it.backdrop_path : PH);
car.appendChild(mkf('pe' + (cur ? ' cur' : ''), `<div class="et"><img loading="lazy" src="${img}" alt=""><i>${n}</i>${cur ? '<span class="eq"><u></u><u></u><u></u></span><span class="pg"><u></u></span>' : ''}</div><span class="pn">${esc(p.name || 'Episode ' + n)}</span><small>${p.runtime ? p.runtime + 'm' : 'Episode ' + n}</small>`, () => playEp(sn, i)));
});
const t = car.querySelector('.cur') || car.firstChild;
if (focus && t) setFocus(t);
}
$('#pep').innerHTML = `<div class="peh"><h3 id="peT"></h3><div class="row" id="pseas"></div><div class="pic f" id="pepx">${PI.x}</div></div><div class="row pcar" id="pcar"></div>`;
$('#pnext').innerHTML = 'Next Episode' + pi('<path d="M6 5.5v13l9-6.5z"/><path d="M18 5.5v13"/>');
$('#pnext')._a = nextEp;
$('#pepx')._a = () => closeEps(1);
$('#peps')._a = () => pl.classList.contains('eps') ? closeEps(1) : openEps();
vd.addEventListener('timeupdate', () => {
const d = vd.duration || 0;
const r = d - vd.currentTime;
$('#pnext').classList.toggle('v', !!d && hasNext() && r <= 300 && r > 0);
$('#pep').style.setProperty('--p', d ? (vd.currentTime / d) * 100 + '%' : '0%');
});
vd.addEventListener('ended', () => {
if (hasNext()) nextEp();
});

View File

@@ -1,70 +0,0 @@
let sqv = '', bgT, stab = 0, SD = { q: '', all: [], mv: [], tv: [] };
const SVS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>';
const sdraw = () => { const t = $('#stext'); t.textContent = sqv || 'Search'; t.classList.toggle('q', !!sqv) };
function setQ(v) { sqv = v; sdraw(); clearTimeout(st); st = setTimeout(() => fillSearch(sqv.trim()), 300) }
function sbgf(it) { clearTimeout(bgT); bgT = setTimeout(() => xf(bgb, I + 'w780' + (it.backdrop_path || it.poster_path)), 120) }
async function fillSearch(q) {
const my = ++sgen; skelSearch();
let all, mv, tv;
const ok = x => x.poster_path && (x.media_type === 'movie' || x.media_type === 'tv');
if (q) {
const d = await api('/search/multi', `&query=${encodeURIComponent(q)}&include_adult=false`);
if (my !== sgen) return;
all = (d.results || []).filter(ok); mv = all.filter(x => x.media_type === 'movie'); tv = all.filter(x => x.media_type === 'tv')
}
else {
const [a, b] = await Promise.all([api('/trending/movie/week'), api('/trending/tv/week')]);
if (my !== sgen) return;
mv = (a.results || []).filter(x => x.poster_path).map(x => ({ ...x, media_type: 'movie' }));
tv = (b.results || []).filter(x => x.poster_path).map(x => ({ ...x, media_type: 'tv' })); all = []
}
SD = { q, all, mv, tv }; drawSearch()
}
function tile(x) {
x._t = x.media_type;
const e = mkf('sc', `<div class="po"><img src="${I}w342${x.poster_path}" alt=""></div><b>${esc(x.title || x.name || '')}</b><small><span>&#9733; ${(x.vote_average || 0).toFixed(1)}</span><span>${yr(x)}</span><span>${x.media_type === 'tv' ? 'Show' : 'Movie'}</span></small>`, () => openDetail(x));
e._bg = x; return e
}
function drawSearch() {
const { q, all, mv, tv } = SD;
chips.innerHTML = '';
const names = [...new Set((q ? all : [...mv, ...tv]).map(x => x.title || x.name))].slice(0, 9);
if (q) chips.appendChild(mkf('chip', SVS + `"${esc(q)}"`, () => { }));
names.forEach(n => chips.appendChild(mkf('chip', esc(n), () => setQ(n))));
sres.innerHTML = '';
const L = stab === 1 ? [['Movies', mv]] : stab === 2 ? [['TV Shows', tv]] : q ? [['Top Matches', all.slice(0, 10)], ['Movies', mv], ['TV Shows', tv]] : [['Trending Movies', mv], ['Trending TV Shows', tv]];
let n = 0;
L.forEach(([h, a]) => { if (!a.length) return; n++; const sec = document.createElement('section'); sec.className = 'ss'; sec.innerHTML = `<h3>${h}</h3><div class="row ch"></div>`; sec.lastChild.append(...a.map(tile)); sres.appendChild(sec) });
if (!n) sres.innerHTML = `<p class="none">No results${q ? ` for \u201c${esc(q)}\u201d` : ''}</p>`;
[...tabs.children].forEach((t, i) => t.classList.toggle('act', i === stab));
if (cur && !document.body.contains(cur)) setFocus(chips.children[1] || chips.firstChild || kb.children[2])
}
['Top Results', 'Movies', 'TV Shows'].forEach((t, i) => tabs.appendChild(mkf('tab', t, () => { stab = i; drawSearch() })));
const KL = 'abcdefghijklmnopqrstuvwxyz'.split(''), KN = "1234567890.-'&:".split('');
let num = false;
function buildKb() {
kb.innerHTML = '';
const mk = (label, cls, fn) => kb.appendChild(mkf('k ' + cls, label, () => { fn(); sr.scrollTo({ top: 0, behavior: 'smooth' }) }));
mk(num ? 'ABC' : '123', 'w', () => { num = !num; buildKb(); setFocus(kb.firstChild) });
mk('SPACE', 'w', () => { if (sqv && !sqv.endsWith(' ')) setQ(sqv + ' ') });
(num ? KN : KL).forEach(c => mk(c, '', () => setQ(sqv + c)));
mk('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5h11a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H9l-6-7z"/><path d="m12 9 5 5m0-5-5 5"/></svg>', 'd', () => setQ(sqv.slice(0, -1)))
}
buildKb();
function openSearch() {
setAct('search'); document.body.classList.add('srch'); sr.classList.add('show'); side.classList.remove('open'); sr.scrollTop = 0;
if (!sres.children.length) fillSearch('');
setFocus(kb.children[2])
}
function closeSearch(silent) {
if (!sr.classList.contains('show')) return;
sr.classList.remove('show'); document.body.classList.remove('srch');
heroIt = null; bgb._u = null;
if (!silent) { setAct(mode); setFocus(document.querySelector('#rows .card')) }
}
function skelSearch() {
chips.innerHTML = SK.chips(7);
sres.innerHTML = [0, 1].map(() => `<section class="ss"><i class="sb h3s"></i><div class="row ch">${SK.tiles(9)}</div></section>`).join('')
}

View File

@@ -1,16 +0,0 @@
const NAV = [
['search', 'Search', ic('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>')],
['discover', 'Discovery', ic('<circle cx="12" cy="12" r="9"/><path d="m15.5 8.5-2 5-5 2 2-5z"/>')],
['movie', 'Movies', ic('<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 3v18M17 3v18M3 8h4M3 12h4M3 16h4M17 8h4M17 12h4M17 16h4"/>')],
['tv', 'TV Shows', ic('<rect x="3" y="7" width="18" height="13" rx="2"/><path d="m8 3 4 4 4-4"/>')],
['wl', 'Watch Later', ic(P_WL)],
['fav', 'Favorites', ic(P_FV)]
];
NAV.forEach(([k, n, s]) => { const e = document.createElement('div'); e.className = 'ni f'; e.tabIndex = -1; e.innerHTML = s + `<span>${n}</span>`; e._a = () => go(k); NAVEL[k] = e; $('#nav').appendChild(e) });
syncNav();
function setAct(k) { Object.entries(NAVEL).forEach(([n, e]) => e.classList.toggle('act', n === k)); navAct = NAVEL[k] }
const mark = $('#mark'); mark.onerror = () => { mark.onerror = null; mark.src = PH }; mark.src = LOGO;
function syncNav() { ['wl', 'fav'].forEach(k => { if (NAVEL[k]) NAVEL[k].style.display = (LIB[k].length || libK === k) ? '' : 'none' }) }

View File

@@ -1,13 +0,0 @@
const SK = {
cards: n => '<div class="card sl"></div>'.repeat(n),
tiles: n => '<div class="sc sl"><div class="po"></div><i class="sb"></i><i class="sb s"></i></div>'.repeat(n),
chips: n => Array.from({ length: n }, (_, i) => `<div class="chip sl" style="width:${90 + (i * 37) % 80}px"></div>`).join(''),
pep: n => '<div class="pe sl"><div class="et"></div><i class="sb" style="width:70%;height:14px;margin-top:11px"></i><i class="sb" style="width:35%;height:11px;margin-top:6px"></i></div>'.repeat(n),
eps: n => '<div class="ep sl"><div class="et"></div><i class="sb" style="width:70%;height:14px;margin-top:12px"></i><i class="sb" style="width:40%;height:11px;margin-top:6px"></i></div>'.repeat(n),
rows: n => Array.from({ length: n }, () => '<section class="sec"><i class="sb h2"></i><div class="row">' + SK.cards(14) + '</div></section>').join(''),
detail: it => {
const bp = it.backdrop_path || it.poster_path || '';
return `<div class="dbl" style="background-image:url(${I}w780${bp})"></div><div class="dw"><div class="dimg" style="background-image:url(${I}w1280${bp})"></div><div class="dc"><div class="dh dsk"><i class="sb t"></i><i class="sb m"></i><div class="hb"><i class="sb b"></i><i class="sb c"></i></div><i class="sb l"></i><i class="sb l"></i><i class="sb l x"></i></div>` +
`<div class="ds"><i class="sb h3"></i><div class="row">${'<div class="trk"></div>'.repeat(4)}</div></div><div class="ds"><i class="sb h3"></i><div class="row">${SK.cards(9)}</div></div></div></div>`
}
};

View File

@@ -4,11 +4,10 @@
"description": "",
"type": "module",
"scripts": {
"start": "node server.js",
"build:web": "node -e \"const fs = require('fs'); fs.rmSync('www', { recursive: true, force: true }); fs.mkdirSync('www'); ['css', 'js', 'assets'].forEach(d => fs.cpSync(d, 'www/' + d, { recursive: true })); fs.copyFileSync('index.html', 'www/index.html');\"",
"dev": "node scripts/build-apk.mjs",
"release": "node scripts/release.mjs",
"android": "node scripts/build-android.mjs && npx cap sync android"
"android": "node scripts/build-android.mjs"
},
"dependencies": {
"@capacitor/android": "^6.2.2",
@@ -27,4 +26,4 @@
"keywords": [],
"author": "",
"license": "ISC"
}
}

View File

@@ -73,4 +73,8 @@ for (const p of ['index.html', 'css', 'js', 'assets']) {
const from = path.join(root, p);
if (fs.existsSync(from)) fs.cpSync(from, path.join(www, p), { recursive: true, filter: s => !s.endsWith('vyla-local.js') });
}
fs.copyFileSync(path.join(root, 'node_modules/hls.js/dist/hls.min.js'), path.join(www, 'js/hls.min.js'));
fs.copyFileSync(path.join(root, 'node_modules/hls.js/dist/hls.min.js'), path.join(www, 'js/hls.min.js'));
const androidAssets = path.join(root, 'android/app/src/main/assets');
if (!fs.existsSync(androidAssets)) fs.mkdirSync(androidAssets, { recursive: true });
fs.copyFileSync(path.join(www, 'js/vyla-local.js'), path.join(androidAssets, 'vyla-local.js'));

View File

@@ -28,7 +28,6 @@ if (j.status !== 0) {
}
run('node', ['scripts/build-android.mjs']);
run('npx', ['cap', 'sync', 'android']);
run(win ? 'gradlew.bat' : './gradlew', ['assembleDebug'], androidDir);
const apk = path.join(androidDir, 'app/build/outputs/apk/debug/app-debug.apk');

348
server.js
View File

@@ -1,348 +0,0 @@
import express from 'express';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
import VylaSDK from '@vyla-entertainment/sdk';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
const sdk = new VylaSDK({
tmdbApiKey: process.env.TMDB_API_KEY || '3f6703f3e2a0b2b40fe4fbb3e1546244'
});
app.use(express.static(__dirname));
function getCleanClientIp(req) {
let ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || '';
if (ip.includes(',')) ip = ip.split(',')[0].trim();
if (ip.includes('::ffff:')) ip = ip.replace('::ffff:', '');
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || !ip) {
return undefined;
}
return ip;
}
app.get('/api/sources', (req, res) => {
try {
const sources = sdk.getSources(true);
res.json({ sources });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
async function isStreamPlayable(streamUrl, headers = {}) {
try {
const fetchOpts = {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
...headers,
'Range': 'bytes=0-1024'
},
redirect: 'follow',
signal: AbortSignal.timeout(6000)
};
const res = await fetch(streamUrl, fetchOpts);
if (![200, 206, 304].includes(res.status)) return false;
const ct = (res.headers.get('content-type') || '').toLowerCase();
const isM3U8 = streamUrl.includes('.m3u8') || ct.includes('mpegurl') || ct.includes('m3u8');
if (isM3U8) {
const text = await res.text();
const lines = text.split('\n');
const baseUrl = streamUrl.substring(0, streamUrl.lastIndexOf('/') + 1);
const originBase = new URL(streamUrl).origin;
let firstTarget = null;
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim();
if (t && !t.startsWith('#')) {
firstTarget = t.startsWith('http') ? t : t.startsWith('/') ? originBase + t : baseUrl + t;
break;
}
}
if (!firstTarget) return false;
if (firstTarget.includes('.m3u8') || firstTarget.includes('m3u8')) {
return await isStreamPlayable(firstTarget, headers);
}
const chunkRes = await fetch(firstTarget, {
method: 'GET',
headers: {
'User-Agent': fetchOpts.headers['User-Agent'],
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
...headers,
'Range': 'bytes=0-1024'
},
redirect: 'follow',
signal: AbortSignal.timeout(6000)
});
return [200, 206, 304].includes(chunkRes.status);
}
return true;
} catch (_) {
return false;
}
}
app.get('/api/stream', async (req, res) => {
const { provider, tmdbId, season, episode } = req.query;
if (!provider || !tmdbId) {
return res.status(400).json({ error: 'Missing provider or tmdbId parameter' });
}
const clientIP = getCleanClientIp(req);
try {
const s = season && season !== 'null' && season !== 'undefined' ? season : undefined;
const e = episode && episode !== 'null' && episode !== 'undefined' ? episode : undefined;
const streamData = await sdk.getStream(
String(provider),
String(tmdbId),
s,
e,
clientIP
);
if (!streamData) return res.status(404).json({ error: 'Stream not found' });
const customHeaders = streamData.headers || {};
if (Array.isArray(streamData.allUrls) && streamData.allUrls.length > 0) {
const checks = await Promise.all(
streamData.allUrls.map(async (u) => {
const ok = u && u.url ? await isStreamPlayable(u.url, customHeaders) : false;
return ok ? u : null;
})
);
streamData.allUrls = checks.filter(Boolean);
if (streamData.allUrls.length > 0) {
if (!streamData.url || !streamData.allUrls.some(u => u.url === streamData.url)) {
streamData.url = streamData.allUrls[0].url;
}
} else {
streamData.url = null;
}
} else if (streamData.url) {
const ok = await isStreamPlayable(streamData.url, customHeaders);
if (!ok) streamData.url = null;
}
if (!streamData.url) {
return res.status(404).json({ error: 'No playable stream found' });
}
res.json({ success: true, provider, stream: streamData });
} catch (err) {
res.status(404).json({ error: err.message });
}
});
app.get('/api/subtitles', async (req, res) => {
const { tmdbId, season, episode } = req.query;
if (!tmdbId) return res.json({ subtitles: [] });
try {
const s = season && season !== 'null' && season !== 'undefined' ? season : undefined;
const e = episode && episode !== 'null' && episode !== 'undefined' ? episode : undefined;
const subtitles = await sdk.getSubtitles(String(tmdbId), s, e);
res.json({ subtitles: subtitles || [] });
} catch (err) {
res.json({ subtitles: [] });
}
});
app.get('/api/timestamps', async (req, res) => {
const { tmdb_id, season, episode, duration_ms } = req.query;
if (!tmdb_id) return res.status(400).json({ error: 'Missing tmdb_id' });
let u = `https://api.theintrodb.org/v3/media?tmdb_id=${tmdb_id}`;
if (season) u += `&season=${season}`;
if (episode) u += `&episode=${episode}`;
if (duration_ms) u += `&duration_ms=${duration_ms}`;
try {
const upstream = await fetch(u);
if (!upstream.ok) return res.json({ segments: [] });
const data = await upstream.json();
const segments = [];
['recap', 'intro', 'preview', 'credits'].forEach(type => {
if (Array.isArray(data[type])) {
data[type].forEach(seg => {
segments.push({
type,
label: type === 'credits' ? 'Skip Credits' : type === 'recap' ? 'Skip Recap' : type === 'preview' ? 'Skip Preview' : 'Skip Intro',
start: seg.start_ms != null ? seg.start_ms / 1000 : 0,
end: seg.end_ms != null ? seg.end_ms / 1000 : null
});
});
}
});
res.json({ segments });
} catch (_) {
res.json({ segments: [] });
}
});
app.all('/api/proxy', async (req, res) => {
const targetUrl = req.query.url;
if (!targetUrl) return res.status(400).send('Missing url parameter');
let customHeaders = {};
if (req.query.headers) {
try { customHeaders = JSON.parse(req.query.headers); } catch (_) { }
}
delete customHeaders['Host'];
delete customHeaders['host'];
const isStrip = req.query.strip === '1' || req.query.tt === '1' || /tiktokcdn\.com|ibyteimg\.com|seg\.html|enproxy|letsgocdn\d+\.shop/i.test(targetUrl);
try {
const fetchOpts = {
method: req.method === 'HEAD' ? 'HEAD' : 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'en-US,en;q=0.9',
...customHeaders
},
redirect: 'follow'
};
if (req.headers.range) fetchOpts.headers['Range'] = req.headers.range;
let response;
let lastErr;
for (let i = 0; i < 3; i++) {
try {
response = await fetch(targetUrl, fetchOpts);
if (response.ok || response.status === 206 || response.status === 404) break;
if (response.status === 403) {
if (i === 2) break;
await new Promise(r => setTimeout(r, 1000));
continue;
}
} catch (err) {
lastErr = err;
if (i === 2) break;
await new Promise(r => setTimeout(r, 500 * (i + 1)));
}
}
if (!response) {
return res.status(502).send(lastErr ? lastErr.message : 'Bad Gateway');
}
res.set({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': '*',
'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges'
});
if (req.method === 'OPTIONS') return res.sendStatus(204);
if (!response.ok && response.status !== 206) {
return res.status(response.status).send(`Upstream error: ${response.status}`);
}
const ct = (response.headers.get('content-type') || '').toLowerCase();
const isM3U8 = targetUrl.includes('.m3u8') || ct.includes('mpegurl') || ct.includes('m3u8');
if (targetUrl.includes('.srt') || ct.includes('subrip')) {
const raw = await response.text();
let vtt = 'WEBVTT\n\n' + raw
.replace(/\r\n|\r/g, '\n')
.replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, '$1.$2');
res.set('Content-Type', 'text/vtt; charset=utf-8');
return res.send(vtt);
}
if (isM3U8) {
const text = await response.text();
const baseUrl = targetUrl.substring(0, targetUrl.lastIndexOf('/') + 1);
const originBase = new URL(targetUrl).origin;
const headersParam = Object.keys(customHeaders).length ? `&headers=${encodeURIComponent(JSON.stringify(customHeaders))}` : '';
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const t = lines[i].trim();
if (!t) continue;
if (t.startsWith('#EXT-X-KEY') || t.startsWith('#EXT-X-MAP')) {
let uIdx = t.indexOf('URI="');
if (uIdx !== -1) {
const end = t.indexOf('"', uIdx + 5);
if (end !== -1) {
const uri = t.slice(uIdx + 5, end);
const abs = uri.startsWith('http') ? uri : uri.startsWith('/') ? originBase + uri : baseUrl + uri;
lines[i] = t.slice(0, uIdx + 5) + `/api/proxy?url=${encodeURIComponent(abs)}${headersParam}` + '"' + t.slice(end + 1);
}
}
} else if (!t.startsWith('#')) {
const abs = t.startsWith('http') ? t : t.startsWith('/') ? originBase + t : baseUrl + t;
const stripParam = isStrip || /seg\.html|enproxy|tiktokcdn|ibyteimg/i.test(abs) ? '&strip=1' : '';
lines[i] = `/api/proxy?url=${encodeURIComponent(abs)}${headersParam}${stripParam}`;
}
}
res.set('Content-Type', 'application/vnd.apple.mpegurl');
return res.send(lines.join('\n'));
}
if (response.headers.has('content-type')) res.set('Content-Type', response.headers.get('content-type'));
if (response.headers.has('content-length')) res.set('Content-Length', response.headers.get('content-length'));
if (response.headers.has('content-range')) res.set('Content-Range', response.headers.get('content-range'));
if (response.headers.has('accept-ranges')) res.set('Accept-Ranges', response.headers.get('accept-ranges'));
if (req.method === 'HEAD') return res.end();
if (isStrip) {
const arrayBuffer = await response.arrayBuffer();
let buffer = Buffer.from(arrayBuffer);
if (buffer.length > 120 && (buffer[0] === 0x89 || buffer[0] === 0xFF || buffer[0] === 0x00)) {
buffer = buffer.subarray(120);
}
res.set('Content-Type', 'video/MP2T');
res.removeHeader('content-length');
return res.send(buffer);
}
res.status(response.status);
if (response.body) {
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
}
res.end();
} else {
res.end();
}
} catch (err) {
res.status(502).send(err.message);
}
});
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});

View File

@@ -1,327 +0,0 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
outline: none;
-webkit-tap-highlight-color: transparent;
-webkit-user-select: none;
user-select: none;
}
:root {
--pad: clamp(56px, 6vw, 132px);
--cw: clamp(120px, 9.4vw, 200px);
}
html,
body {
height: 100%;
background: #07070a;
color: #f2f2f5;
font-family: "Inter", "SF Pro Display", "Segoe UI", Roboto, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
overflow: hidden;
cursor: default;
overscroll-behavior: none;
touch-action: pan-x pan-y;
}
.card,
.sc,
.pe,
.tr,
.ep {
transform: translateZ(0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
::-webkit-scrollbar {
display: none
}
.f {
outline: none;
cursor: pointer
}
.xf {
position: absolute;
inset: 0;
background-size: cover;
background-position: center top;
opacity: 0;
transition: opacity .7s ease
}
.xf.in {
opacity: 1
}
#bg {
position: fixed;
inset: -40px;
background: #07070a;
filter: blur(42px) brightness(.55) saturate(1)
}
#dim {
position: fixed;
inset: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, .05) 0%, rgba(0, 0, 0, .55) 55%, #000 100%)
}
#dim2 {
position: fixed;
inset: 0;
background: #000;
opacity: 0;
pointer-events: none
}
#main {
position: fixed;
inset: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none
}
.hero {
position: relative;
height: 60vh
}
#hb {
position: absolute;
inset: 0;
-webkit-mask-image: linear-gradient(180deg, #000 50%, transparent 100%);
mask-image: linear-gradient(180deg, #000 50%, transparent 100%)
}
#hb::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(0, 0, 0, .8) 0%, rgba(0, 0, 0, .35) 40%, transparent 70%)
}
.hc {
position: absolute;
left: var(--pad);
bottom: 5vh;
width: min(48vw, 680px);
z-index: 2
}
#hlogo {
height: 16vh;
display: flex;
align-items: flex-end;
margin-bottom: 2vh
}
#hlogo img {
max-width: min(34vw, 440px);
max-height: 16vh;
object-fit: contain;
filter: drop-shadow(0 4px 18px rgba(0, 0, 0, .6))
}
#hlogo h1 {
font-size: clamp(28px, 3.6vw, 64px);
font-weight: 700;
letter-spacing: -.02em;
line-height: 1.02;
text-shadow: 0 4px 24px rgba(0, 0, 0, .6)
}
.meta {
display: flex;
gap: 14px;
align-items: center;
font-size: clamp(13px, 1.05vw, 19px);
color: #e3e3ee;
margin-bottom: 1.4vh;
font-weight: 500
}
.meta .b {
background: rgba(255, 255, 255, .18);
border-radius: 6px;
padding: 2px 8px;
font-size: .85em;
font-weight: 600
}
.ov1 {
font-size: clamp(13px, 1.1vw, 20px);
line-height: 1.5;
color: #d4d4e2;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 2.4vh;
max-width: 640px
}
.hb {
display: flex;
gap: 14px;
align-items: center
}
.pb,
.ib {
border: 0;
font: inherit;
font-weight: 600;
font-size: clamp(14px, 1.1vw, 20px);
border-radius: 999px;
height: clamp(44px, 3.6vw, 64px);
padding: 0 clamp(28px, 2.6vw, 48px);
display: inline-flex;
align-items: center;
gap: 10px;
transition: transform .2s, background .2s
}
.pb {
background: #fff;
color: #000
}
.ib {
background: rgba(255, 255, 255, .16);
color: #fff;
backdrop-filter: blur(12px);
padding: 0 clamp(20px, 2vw, 36px)
}
.pb.on,
.ib.on {
transform: scale(1.04);
}
.ib.on {
background: rgba(255, 255, 255, .3)
}
.sec {
padding-top: 10px
}
.sec h2 {
padding: 0 var(--pad);
font-size: clamp(16px, 1.5vw, 26px);
font-weight: 700;
margin-bottom: 4px;
text-shadow: 0 2px 10px rgba(0, 0, 0, .6)
}
.row {
display: flex;
gap: 16px;
overflow-x: auto;
overflow-y: hidden;
padding: 22px var(--pad) 26px;
position: relative;
scrollbar-width: none;
min-height: calc(var(--cw)*1.5 + 48px)
}
.card {
flex: 0 0 var(--cw);
height: calc(var(--cw)*1.5);
border-radius: 14px;
overflow: hidden;
background: #1b1b24;
position: relative;
transform: translateZ(0);
transition: transform .38s cubic-bezier(.22, .9, .3, 1);
will-change: transform
}
.card img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
filter: brightness(.86);
transition: filter .38s
}
.card::after {
content: "";
position: absolute;
inset: 0;
z-index: 3;
border-radius: inherit;
opacity: 0;
transition: opacity .25s;
pointer-events: none
}
.card.on {
transform: scale(1.06) translateY(-4px);
z-index: 2
}
.card.on img {
filter: none
}
.card.on::after {
opacity: 1
}
.kbm,
.kbm * {
cursor: none !important
}
#sentinel {
height: 40vh
}
.none {
color: rgba(255, 255, 255, .6);
padding: 5vh 0;
font-size: 18px
}
.toast {
position: fixed;
left: 50%;
bottom: 7vh;
transform: translateX(-50%);
z-index: 200;
background: #fff;
color: #000;
padding: 12px 22px;
border-radius: 999px;
font-weight: 600;
font-size: 15px;
animation: tin .25s both, tout .3s 1.9s forwards
}
@keyframes tin {
from {
opacity: 0;
transform: translate(-50%, 14px)
}
to {
opacity: 1;
transform: translate(-50%, 0)
}
}
@keyframes tout {
to {
opacity: 0
}
}

View File

@@ -1,447 +0,0 @@
#ov {
position: fixed;
inset: 0;
z-index: 50;
background: #000;
overflow-y: auto;
opacity: 0;
pointer-events: none;
transform: scale(1.04);
transition: opacity .35s, transform .35s;
scrollbar-width: none
}
#ov.show {
opacity: 1;
pointer-events: auto;
transform: none
}
.dbl {
position: fixed;
inset: -60px;
background-size: cover;
background-position: center;
filter: blur(60px) brightness(.5) saturate(1)
}
.dw {
position: relative;
min-height: 100vh
}
.dimg {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 82vh;
background-size: cover;
background-position: center top;
-webkit-mask-image: linear-gradient(180deg, #000 45%, transparent 100%);
mask-image: linear-gradient(180deg, #000 45%, transparent 100%)
}
.dc {
position: relative;
padding: 40vh 0 8vh
}
.dc>.dh {
padding: 0 6vw
}
.dh #dlogo {
height: 20vh;
display: flex;
align-items: flex-end;
margin-bottom: 2vh
}
.dh #dlogo img {
max-width: min(40vw, 540px);
max-height: 20vh;
object-fit: contain;
filter: drop-shadow(0 4px 18px rgba(0, 0, 0, .6))
}
#dback {
position: fixed;
top: 3.5vh;
left: 3.5vw;
z-index: 12;
background: rgba(255, 255, 255, .12);
backdrop-filter: blur(12px);
}
#dback.on {
background: #fff;
color: #000;
transform: scale(1.08);
}
.dh h1 {
font-size: clamp(30px, 4.4vw, 76px);
font-weight: 700;
letter-spacing: -.02em;
line-height: 1
}
.dh .meta {
margin-top: 1vh
}
.dh .hb {
margin: 2vh 0
}
.circ {
width: clamp(44px, 3.6vw, 64px);
height: clamp(44px, 3.6vw, 64px);
border-radius: 50%;
background: rgba(255, 255, 255, .12);
backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
}
.circ.on {
transform: scale(1.1);
}
.otx {
max-width: min(70vw, 980px);
font-size: clamp(13px, 1.1vw, 20px);
line-height: 1.55;
color: #d9d9e4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden
}
.otx.x {
-webkit-line-clamp: unset;
line-clamp: unset;
display: block
}
.more {
display: inline-block;
margin-top: 10px;
background: rgba(255, 255, 255, .18);
border-radius: 999px;
padding: 5px 14px;
font-size: 13px;
font-weight: 700;
}
.more.on {
transform: scale(1.08);
}
.ds {
margin-top: 3vh
}
.ds h3 {
padding: 0 6vw;
font-size: clamp(17px, 1.5vw, 26px);
font-weight: 700
}
.ds .row {
padding-left: 6vw;
padding-right: 6vw;
min-height: 0
}
.tr {
flex: 0 0 clamp(250px, 21vw, 400px);
border-radius: 20px;
position: relative;
overflow: hidden;
background: #101016;
padding: 0 0 18px;
isolation: isolate;
}
.tr .tb {
position: absolute;
inset: -40px;
background-size: cover;
background-position: center;
filter: blur(34px) brightness(.62) saturate(1.35);
transform: scale(1.2);
z-index: -1
}
.tr .th {
aspect-ratio: 16/9;
background-size: cover;
background-position: center;
margin: 0 0 -26px;
-webkit-mask-image: linear-gradient(180deg, #000 50%, rgba(0, 0, 0, .55) 74%, transparent 100%);
mask-image: linear-gradient(180deg, #000 50%, rgba(0, 0, 0, .55) 74%, transparent 100%)
}
.tr b,
.tr small {
display: block;
padding: 0 18px;
position: relative
}
.tr b {
font-size: clamp(14px, 1.1vw, 19px);
font-weight: 600
}
.tr small {
color: #d0d0da;
margin-top: 5px
}
.tr.on {
transform: scale(1.04);
}
.cs {
flex: 0 0 clamp(96px, 7.6vw, 150px);
text-align: center;
transition: transform .22s
}
.cs .pf {
width: clamp(90px, 7vw, 130px);
height: clamp(90px, 7vw, 130px);
border-radius: 50%;
margin: 0 auto 10px;
background: #2a2a3c center/cover;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 700;
}
.cs b {
display: block;
font-size: clamp(12px, .95vw, 16px)
}
.cs small {
display: block;
color: #aaa;
font-size: clamp(11px, .8vw, 14px);
margin-top: 2px
}
.cs.on {
transform: scale(1.07)
}
.circ {
position: relative
}
.circ svg {
width: 46%;
height: 46%
}
.circ.sv {
background: #fff;
color: #000
}
#dfv.sv svg {
fill: currentColor
}
.circ[data-l].on::after {
content: attr(data-l);
position: absolute;
top: calc(100% + 12px);
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
font-size: 13px;
font-weight: 600;
background: #fff;
color: #000;
padding: 5px 12px;
border-radius: 999px;
z-index: 5;
pointer-events: none
}
.mx {
display: none;
gap: 14px;
align-items: center
}
.mx.open {
display: flex
}
.mx.open .circ {
animation: pop .32s cubic-bezier(.22, .9, .3, 1) backwards
}
.mx.open .circ+.circ {
animation-delay: .06s
}
@keyframes pop {
from {
opacity: 0;
transform: scale(.4) translateX(-24px)
}
}
.eps {
padding: 0 6vw
}
.eh {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 14px
}
.eps .eh h3 {
padding: 0
}
.dd {
position: relative;
z-index: 6
}
.ddb {
display: flex;
align-items: center;
gap: 10px;
height: 42px;
padding: 0 18px;
border-radius: 999px;
background: rgba(255, 255, 255, .16);
backdrop-filter: blur(12px);
font-weight: 600;
font-size: clamp(13px, 1vw, 17px);
transition: background .2s, color .2s, transform .25s
}
.ddb svg {
width: 16px;
height: 16px
}
.ddb.on {
background: #fff;
color: #000;
transform: scale(1.04)
}
.ddl {
display: none;
position: absolute;
top: calc(100% + 8px);
left: 0;
min-width: 240px;
max-height: 46vh;
overflow-y: auto;
padding: 6px;
border-radius: 16px;
background: rgba(22, 22, 30, .97);
scrollbar-width: none
}
.ddl.open {
display: block
}
.ddi {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 12px 14px;
border-radius: 10px;
font-weight: 500;
white-space: nowrap
}
.ddi small {
color: rgba(255, 255, 255, .5)
}
.ddi.on {
background: #fff;
color: #000
}
.ddi.on small {
color: rgba(0, 0, 0, .55)
}
.el {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(clamp(210px, 17vw, 320px), 1fr));
gap: 28px 20px;
padding: 8px 6px 14px;
margin: 0 -6px
}
.el .none {
grid-column: 1/-1
}
.ep {
min-width: 0
}
.ep .et {
position: relative;
aspect-ratio: 16/9;
border-radius: 10px;
overflow: hidden;
background: #000;
}
.ep .et img {
width: 100%;
height: 100%;
object-fit: cover;
display: block
}
.ep b {
display: block;
margin-top: 12px;
font-size: clamp(13px, 1vw, 16px);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis
}
.ep small {
display: block;
margin-top: 3px;
font-size: clamp(12px, .9vw, 14px);
color: rgba(255, 255, 255, .5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis
}
.ep.on .et {
transform: scale(1.03)
}

View File

@@ -1,65 +0,0 @@
#lib {
position: fixed;
inset: 0;
z-index: 25;
background: linear-gradient(180deg, rgba(0, 0, 0, .25), rgba(0, 0, 0, .7));
overflow-y: auto;
opacity: 0;
pointer-events: none;
transition: opacity .35s;
padding: 9vh var(--pad) 10vh;
scrollbar-width: none
}
#lib.show {
opacity: 1;
pointer-events: auto
}
.lib #main {
visibility: hidden
}
.lib #bg {
filter: blur(70px) brightness(.5) saturate(1.5)
}
.lib #dim {
opacity: .35
}
.lib #dim2 {
opacity: 0 !important
}
#lib h1 {
font-size: clamp(28px, 3.4vw, 56px);
font-weight: 700;
letter-spacing: -.02em;
display: flex;
align-items: baseline;
gap: 18px
}
#lib h1 small {
font-size: clamp(13px, 1.1vw, 19px);
font-weight: 500;
color: rgba(255, 255, 255, .6);
letter-spacing: 0
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--cw), 1fr));
gap: 22px;
padding: 30px 10px
}
.grid .card {
height: auto;
aspect-ratio: 2/3
}
.grid .none {
grid-column: 1/-1
}

View File

@@ -1,705 +0,0 @@
#pl {
position: fixed;
inset: 0;
z-index: 80;
background: #000;
opacity: 0;
pointer-events: none;
transition: opacity .35s;
}
#pl.show {
opacity: 1;
pointer-events: auto;
}
#pl:not(.ui) {
cursor: none;
}
#pv {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
z-index: 0;
}
#pl.cover #pv {
object-fit: cover;
}
video::cue {
font: 600 clamp(16px, 1.8vw, 30px) Inter, sans-serif;
background: rgba(0, 0, 0, .55);
color: #fff;
}
.sub-active::cue {
color: var(--sub-color, #ffffff) !important;
background-color: var(--sub-bg, rgba(0, 0, 0, 0.75)) !important;
font-size: var(--sub-size, 22px) !important;
text-shadow: var(--sub-shadow, 0 2px 4px rgba(0, 0, 0, 0.8)) !important;
}
.sub-ctrl-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
font-size: 14px;
}
.sub-ctrl-row span {
color: rgba(255, 255, 255, 0.8);
}
.sub-btn-group {
display: flex;
gap: 6px;
}
.sub-btn-group button {
background: rgba(255, 255, 255, 0.15);
border: none;
color: #fff;
padding: 4px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.sub-btn-group button.active {
background: #fff;
color: #000;
font-weight: 600;
}
.src-group {
display: flex;
flex-direction: column;
margin-bottom: 4px;
}
.src-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 14px;
border-radius: 12px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
background: rgba(255, 255, 255, 0.08);
transition: background 0.15s, color 0.15s;
}
.src-header.on,
.src-header:hover {
background: #fff;
color: #000;
}
.src-expand-btn {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.16);
color: inherit;
margin-left: 8px;
}
.src-sublist {
display: none;
flex-direction: column;
gap: 2px;
padding-left: 14px;
margin: 4px 0;
}
.src-sublist.open {
display: flex;
}
.src-subitem {
display: flex;
align-items: center;
justify-content: space-between;
height: 38px;
padding: 0 12px;
border-radius: 8px;
font-size: 13px;
background: rgba(255, 255, 255, 0.04);
cursor: pointer;
}
.src-subitem.on,
.src-subitem:hover {
background: rgba(255, 255, 255, 0.25);
color: #fff;
}
#pl .top,
#pl .bot,
#pmid {
opacity: 0;
transition: opacity .3s, transform .25s, background .2s;
}
#pl.ui .top,
#pl.ui .bot,
#pl.ui #pmid {
opacity: 1;
}
#pl .f {
pointer-events: auto;
}
#pl:not(.ui) .f {
pointer-events: none;
}
#pl .top,
#pl .bot {
position: absolute;
left: 0;
right: 0;
pointer-events: none;
}
#pl .top {
top: 0;
padding: 2.6vh 2.4vw 14vh;
display: flex;
align-items: flex-start;
background: linear-gradient(180deg, rgba(0, 0, 0, .65), transparent);
}
#pl .bot {
bottom: 0;
padding: 10vh 2.4vw 2.6vh;
display: flex;
align-items: center;
gap: 18px;
background: linear-gradient(0deg, rgba(0, 0, 0, .7), transparent);
}
.pic {
width: clamp(44px, 3.3vw, 56px);
height: clamp(44px, 3.3vw, 56px);
flex: 0 0 auto;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: background .2s, color .2s, transform .2s;
}
.pic svg {
width: 52%;
height: 52%;
}
.pic.on {
background: #fff;
color: #000;
transform: scale(1.08);
}
.av {
width: 46px;
height: 46px;
margin: 3px 12px 0 6px;
border-radius: 50%;
object-fit: cover;
background: #2a2a34;
}
.pt {
height: 46px;
margin-top: 3px;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.pt b {
font-size: clamp(15px, 1.2vw, 19px);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 36vw;
}
.pt small {
font-size: clamp(12px, .9vw, 14px);
color: rgba(255, 255, 255, .7);
margin-top: 2px;
}
.pr {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
#pmid {
position: absolute;
left: 50%;
top: 50%;
width: 112px;
height: 112px;
margin: -56px 0 0 -56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
#pmid svg {
width: 72px;
height: 72px;
filter: drop-shadow(0 2px 14px rgba(0, 0, 0, .45));
}
#pmid.on {
background: rgba(255, 255, 255, .18);
transform: scale(1.08);
}
.bt {
font-size: 14px;
font-variant-numeric: tabular-nums;
min-width: 44px;
color: #fff;
}
#pdur {
text-align: right;
}
.sk {
flex: 1;
height: 40px;
position: relative;
}
.sk::before,
.sk u,
.sk i {
content: "";
position: absolute;
left: 0;
top: 50%;
height: 4px;
margin-top: -2px;
border-radius: 2px;
transition: height .15s, margin .15s;
}
.sk::before {
right: 0;
background: rgba(255, 255, 255, .2);
}
.sk u {
width: 0;
background: rgba(255, 255, 255, .38);
}
.sk i {
width: 0;
background: #fff;
}
.sk b {
position: absolute;
top: 50%;
left: 0;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border-radius: 50%;
background: #fff;
transition: transform .15s;
}
.sk.on::before,
.sk.on u,
.sk.on i {
height: 6px;
margin-top: -3px;
}
.sk.on b {
transform: scale(1.35);
}
#pm {
display: none;
position: absolute;
top: calc(2.6vh + 66px);
right: 2.4vw;
width: 320px;
max-height: 70vh;
overflow-y: auto;
padding: 10px;
border-radius: 18px;
background: rgba(0, 0, 0, .94);
backdrop-filter: blur(24px);
z-index: 5;
pointer-events: auto;
scrollbar-width: none;
}
#pm.open {
display: block;
}
#pm h4 {
font-size: 12px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: rgba(255, 255, 255, .5);
padding: 10px 12px 6px;
}
#pm p {
font-size: 11px;
line-height: 1.4;
color: rgba(255, 255, 255, .4);
padding: 10px 12px 4px;
}
.pi {
display: flex;
align-items: center;
justify-content: space-between;
height: 48px;
padding: 0 14px;
border-radius: 12px;
font-size: 15px;
font-weight: 500;
transition: background .15s, color .15s;
}
.pi svg {
width: 20px;
height: 20px;
}
.pi.on {
background: #fff;
color: #000;
}
.vrow {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 6px 8px;
}
.vrow span {
width: 30px;
text-align: right;
font-size: 13px;
color: rgba(255, 255, 255, .7);
}
.vs {
flex: 1;
height: 36px;
position: relative;
border-radius: 8px;
}
.vs::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 4px;
margin-top: -2px;
border-radius: 2px;
background: rgba(255, 255, 255, .2);
}
.vs i {
position: absolute;
left: 0;
top: 50%;
height: 4px;
margin-top: -2px;
border-radius: 2px;
background: #fff;
}
.skip {
display: none;
height: 48px;
padding: 0 26px;
border-radius: 999px;
background: rgba(255, 255, 255, .18);
backdrop-filter: blur(16px);
align-items: center;
font-weight: 600;
transition: transform .25s, background .2s, color .2s;
}
.skip.v {
display: flex;
}
.skip.on {
background: #fff;
color: #000;
transform: scale(1.06);
}
#pact {
position: absolute;
right: 2.4vw;
bottom: 16vh;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
}
.skip svg {
width: 18px;
height: 18px;
margin-left: 10px;
}
#pnext {
background: #fff;
color: #000;
}
#pnext.on {
color: #000;
}
#pl.eps .bot,
#pl.eps #pmid,
#pl.eps #pact {
opacity: 0 !important;
visibility: hidden;
pointer-events: none;
}
#pep {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 9vh 0 3.4vh;
background: linear-gradient(0deg, rgba(0, 0, 0, .97) 60%, rgba(0, 0, 0, .8) 82%, transparent);
transform: translateY(40px);
opacity: 0;
visibility: hidden;
transition: transform .45s cubic-bezier(.22, .9, .3, 1), opacity .3s, visibility 0s .45s;
}
#pl.eps #pep {
transform: none;
opacity: 1;
visibility: visible;
transition-delay: 0s;
}
.peh {
display: flex;
align-items: center;
gap: 22px;
padding: 0 2.4vw;
}
.peh h3 {
font-size: clamp(18px, 1.6vw, 26px);
font-weight: 700;
display: flex;
align-items: baseline;
gap: 14px;
white-space: nowrap;
}
.peh h3 small {
font-size: .62em;
font-weight: 500;
color: rgba(255, 255, 255, .55);
max-width: 22vw;
overflow: hidden;
text-overflow: ellipsis;
}
#pep #pseas {
flex: 1;
min-width: 0;
padding: 4px;
margin: 0;
min-height: 0;
gap: 10px;
}
#pep .chip {
height: 38px;
padding: 0 18px;
font-size: 14px;
background: rgba(255, 255, 255, .12);
}
#pep .chip.act {
background: rgba(255, 255, 255, .3);
}
#pep .chip.on {
background: #fff;
color: #000;
transform: scale(1.05);
}
#pep .pcar {
padding: 18px 2.4vw 8px;
min-height: 0;
gap: 18px;
-webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 5vw), transparent);
mask-image: linear-gradient(90deg, #000 calc(100% - 5vw), transparent);
}
.pe {
flex: 0 0 clamp(230px, 19vw, 330px);
min-width: 0;
}
.pe .et {
position: relative;
aspect-ratio: 16/9;
border-radius: 12px;
overflow: hidden;
background: #000;
}
.pe .et img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.pe .et>i {
position: absolute;
left: 10px;
top: 10px;
font-style: normal;
font-size: 12px;
font-weight: 700;
padding: 3px 9px;
border-radius: 999px;
background: rgba(0, 0, 0, .6);
backdrop-filter: blur(8px);
}
.pe .pn {
display: block;
margin-top: 11px;
font-size: clamp(13px, 1vw, 16px);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pe small {
display: block;
margin-top: 3px;
font-size: 13px;
color: rgba(255, 255, 255, .5);
}
.pe.on .et {
transform: scale(1.04);
}
.pe .pg {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, .22);
}
.pe .pg u,
.eq u {
display: block;
text-decoration: none;
}
.pe .pg u {
height: 100%;
width: var(--p, 0%);
}
.eq {
position: absolute;
right: 10px;
bottom: 14px;
display: flex;
gap: 3px;
align-items: flex-end;
height: 16px;
}
.eq u {
width: 3px;
height: 4px;
border-radius: 2px;
animation: eq 1s ease-in-out infinite;
}
.eq u:nth-child(2) {
animation-delay: .2s;
}
.eq u:nth-child(3) {
animation-delay: .4s;
}
@keyframes eq {
50% {
height: 16px;
}
}
#pbg {
position: absolute;
inset: -60px;
width: calc(100% + 120px);
height: calc(100% + 120px);
object-fit: cover;
filter: blur(54px) brightness(0.42) saturate(1.45);
transform: scale(1.08);
pointer-events: none;
z-index: 0;
opacity: 1;
transition: opacity 0.4s ease;
}
#pl.cover #pbg {
opacity: 0;
visibility: hidden;
}

View File

@@ -1,273 +0,0 @@
.srch #main {
visibility: hidden
}
.srch #bg {
filter: blur(70px) brightness(.5) saturate(1.5)
}
.srch #dim {
opacity: .35
}
.srch #dim2 {
opacity: 0 !important
}
#sr {
position: fixed;
inset: 0;
z-index: 25;
background: linear-gradient(180deg, rgba(0, 0, 0, .2), rgba(0, 0, 0, .6));
overflow-y: auto;
opacity: 0;
pointer-events: none;
transition: opacity .35s;
padding: 6vh var(--pad) 8vh;
scrollbar-width: none
}
#sr.show {
opacity: 1;
pointer-events: auto
}
.stop {
display: flex;
align-items: center;
gap: 18px;
height: clamp(46px, 4.6vw, 76px)
}
.stop svg {
width: clamp(30px, 2.8vw, 46px);
height: clamp(30px, 2.8vw, 46px);
color: rgba(255, 255, 255, .85);
flex: 0 0 auto
}
#stext {
font-size: clamp(28px, 3vw, 52px);
font-weight: 600;
letter-spacing: -.02em;
color: rgba(255, 255, 255, .5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 70vw
}
#stext.q {
color: #fff
}
#stext.q::after {
content: "";
display: inline-block;
width: 3px;
height: .8em;
background: #fff;
margin-left: 5px;
vertical-align: -.08em;
animation: bl 1s steps(1) infinite
}
@keyframes bl {
50% {
opacity: 0
}
}
#kb {
display: flex;
align-items: center;
justify-content: space-between;
gap: clamp(2px, .35vw, 8px);
margin: 1.4vh 0 0 4px;
width: min(100%, 1180px)
}
.k {
flex: 1 1 0;
min-width: 0;
max-width: 46px;
height: clamp(34px, 3vw, 52px);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(16px, 1.6vw, 26px);
font-weight: 500;
color: rgba(255, 255, 255, .88);
transition: background .2s, color .2s, transform .22s cubic-bezier(.22, .9, .3, 1)
}
.k.w {
flex: 0 0 auto;
max-width: none;
font-size: clamp(9px, .8vw, 13px);
font-weight: 700;
padding: 0 clamp(8px, .8vw, 14px);
background: rgba(255, 255, 255, .22);
letter-spacing: .04em;
height: clamp(26px, 2.3vw, 40px)
}
.k.d svg {
width: 1.1em;
height: 1.1em
}
.k.on {
background: #fff;
color: #000;
transform: scale(1.22);
}
.row.ch {
padding: 16px 6px 18px;
min-height: 0;
margin: 0 -6px;
gap: 14px
}
.chip {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
padding: 0 20px;
height: clamp(36px, 3vw, 52px);
border-radius: 12px;
background: rgba(255, 255, 255, .24);
color: #fff;
font-size: clamp(14px, 1.3vw, 22px);
font-weight: 500;
white-space: nowrap;
transition: background .2s, color .2s, transform .22s cubic-bezier(.22, .9, .3, 1)
}
.chip svg {
width: 1.1em;
height: 1.1em
}
.chip.on {
background: #fff;
color: #000;
transform: scale(1.06)
}
.tabs {
display: flex;
justify-content: space-around;
border-top: 1px solid rgba(255, 255, 255, .22);
margin-top: 1vh;
padding-top: 1.6vh
}
.tab {
padding: 8px 24px;
border-radius: 999px;
font-size: clamp(13px, 1.15vw, 19px);
color: rgba(255, 255, 255, .65);
position: relative;
transition: background .2s, color .2s
}
.tab.act {
color: #fff;
font-weight: 600
}
.tab.act::after {
content: "";
position: absolute;
left: 24px;
right: 24px;
bottom: 0;
height: 2px;
background: #fff;
border-radius: 2px
}
.tab.on {
background: rgba(255, 255, 255, .2);
color: #fff
}
.ss {
margin-top: 2.6vh
}
.ss h3 {
font-size: clamp(14px, 1.2vw, 20px);
font-weight: 500;
color: rgba(255, 255, 255, .75)
}
.sc {
flex: 0 0 var(--cw);
width: var(--cw);
max-width: var(--cw);
min-width: 0;
transition: transform .32s cubic-bezier(.22, .9, .3, 1);
}
.sc .po {
width: 100%;
height: calc(var(--cw) * 1.5);
aspect-ratio: 2/3;
border-radius: 14px;
overflow: hidden;
background: #1b1b24;
}
.sc .po img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
filter: brightness(.86);
transition: filter .3s
}
.sc b {
display: block;
margin-top: 10px;
font-size: clamp(13px, 1vw, 17px);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis
}
.sc small {
display: flex;
gap: 8px;
margin-top: 3px;
color: rgba(255, 255, 255, .62);
font-size: clamp(11px, .85vw, 14px);
white-space: nowrap
}
.sc small span+span::before {
content: "\2022";
margin-right: 8px;
opacity: .5
}
.sc.on {
transform: scale(1.05) translateY(-3px)
}
.sc.on img {
filter: none
}

View File

@@ -1,114 +0,0 @@
#side {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 340px;
z-index: 30;
pointer-events: none
}
#side::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(0, 0, 0, .92) 0%, rgba(0, 0, 0, .7) 55%, transparent 100%);
opacity: 0;
transition: opacity .35s
}
#side.open::before {
opacity: 1
}
#side nav {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: auto
}
.ni {
margin-left: 16px;
width: 56px;
height: 56px;
border-radius: 28px;
display: flex;
align-items: center;
gap: 18px;
padding: 0 16px;
color: #fff;
opacity: .45;
overflow: hidden;
white-space: nowrap;
transition: width .32s cubic-bezier(.2, .8, .2, 1), background .25s, color .25s, opacity .25s
}
.ni svg {
width: 24px;
height: 24px;
flex: 0 0 24px
}
.ni span {
font-size: 19px;
font-weight: 600;
opacity: 0;
transition: opacity .25s
}
.ni.act {
opacity: 1
}
#side.open .ni {
width: 260px;
opacity: .85
}
#side.open .ni span {
opacity: 1
}
#side.open .ni.on {
background: #fff;
color: #000;
opacity: 1
}
.brand {
position: absolute;
left: 24px;
top: 5vh;
display: flex;
align-items: center;
gap: 14px;
height: 44px;
color: #fff
}
.brand svg,
.brand img {
width: 40px;
height: 40px;
flex: 0 0 40px;
object-fit: contain
}
.brand span {
font-size: 24px;
font-weight: 700;
letter-spacing: -.03em;
opacity: 0;
transform: translateX(-6px);
transition: opacity .3s, transform .3s
}
#side.open .brand span {
opacity: 1;
transform: none
}

213
www/css/skeleton.css vendored
View File

@@ -1,213 +0,0 @@
.sb,
.card:not(.lded),
.sc .po:not(.lded),
.ep .et:not(.lded),
.cs.sl .pf,
.trk,
.chip.sl {
position: relative;
overflow: hidden;
background: #15151c
}
.sb::before,
.card:not(.lded)::before,
.sc .po:not(.lded)::before,
.ep .et:not(.lded)::before,
.cs.sl .pf::before,
.trk::before,
.chip.sl::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 25%, rgba(255, 255, 255, .075) 50%, transparent 75%);
transform: translateX(-100%);
animation: shim 1.5s ease-in-out infinite
}
@keyframes shim {
to {
transform: translateX(100%)
}
}
.card img,
.sc .po img,
.ep .et img {
opacity: 0;
transition: opacity .45s ease, filter .38s
}
.card img.ld,
.sc .po img.ld,
.ep .et img.ld {
opacity: 1
}
.sb {
display: block;
border-radius: 8px;
height: 14px
}
.sec>.sb.h2 {
width: 220px;
height: 22px;
margin: 0 var(--pad) 4px
}
.ds>.sb.h3 {
width: 180px;
height: 22px;
margin: 0 6vw 6px
}
.ss>.sb.h3s {
width: 160px;
height: 18px
}
.sc.sl .sb {
width: 80%;
height: 13px;
margin-top: 10px
}
.sc.sl .sb.s {
width: 50%;
height: 11px;
margin-top: 6px
}
.chip.sl {
height: clamp(36px, 3vw, 52px)
}
.trk {
flex: 0 0 clamp(250px, 21vw, 400px);
aspect-ratio: 16/10;
border-radius: 20px
}
.cs.sl {
flex: 0 0 clamp(96px, 7.6vw, 150px)
}
.hskel {
display: none
}
.hc.sl .hskel {
display: block
}
.hc.sl>:not(.hskel) {
display: none
}
.hskel .t,
.dsk .t {
width: min(32vw, 400px);
height: 10vh;
border-radius: 14px;
margin-bottom: 3vh
}
.hskel .m,
.dsk .m {
width: min(22vw, 300px);
height: 18px;
margin-bottom: 2vh
}
.hskel .l,
.dsk .l {
width: min(44vw, 620px);
height: 15px;
margin-bottom: 10px
}
.hskel .l.x,
.dsk .l.x {
width: min(26vw, 360px)
}
.hskel .hb,
.dsk .hb {
margin: 3vh 0
}
.hskel .b,
.dsk .b {
width: clamp(130px, 11vw, 190px);
height: clamp(44px, 3.6vw, 64px);
border-radius: 999px
}
.hskel .b.c,
.dsk .c {
width: clamp(44px, 3.6vw, 64px);
border-radius: 50%
}
.dw {
animation: fin .4s ease
}
@keyframes fin {
from {
opacity: 0
}
}
#pspin {
position: absolute;
left: 50%;
top: 50%;
width: 64px;
height: 64px;
margin: -32px 0 0 -32px;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, .2);
border-top-color: #fff;
animation: spin .8s linear infinite;
opacity: 0;
pointer-events: none;
transition: opacity .2s
}
#pl.buf #pspin {
opacity: 1
}
#pl.buf #pmid {
opacity: 0 !important
}
@keyframes spin {
to {
transform: rotate(360deg)
}
}
.pe .et:not(.lded)::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 25%, rgba(255, 255, 255, .075) 50%, transparent 75%);
transform: translateX(-100%);
animation: shim 1.5s ease-in-out infinite
}
.pe .et:not(.lded) {
background: #15151c
}
.pe .et img {
opacity: 0;
transition: opacity .45s ease
}
.pe .et img.ld {
opacity: 1
}

View File

@@ -1,94 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Vyla</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/sidebar.css">
<link rel="stylesheet" href="css/detail.css">
<link rel="stylesheet" href="css/search.css">
<link rel="stylesheet" href="css/library.css">
<link rel="stylesheet" href="css/player.css">
<link rel="stylesheet" href="css/skeleton.css">
<script src="js/hls.min.js"></script>
</head>
<body>
<div id="bg"></div>
<div id="dim"></div>
<div id="dim2"></div>
<div id="main">
<div class="hero">
<div id="hb"></div>
<div class="hc sl">
<div class="hskel"><i class="sb t"></i><i class="sb m"></i><i class="sb l"></i><i class="sb l"></i><i
class="sb l x"></i>
<div class="hb"><i class="sb b"></i><i class="sb b c"></i></div>
</div>
<div id="hlogo"></div>
<div class="meta" id="hmeta"></div>
<p class="ov1" id="hov"></p>
<div class="hb"><button class="pb f" id="hplay">&#9654; Play</button><button class="ib f"
id="hinfo">&#9432; More Info</button></div>
</div>
</div>
<div id="rows"></div>
<div id="sentinel"></div>
</div>
<div id="sr">
<div class="stop"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg><span id="stext">Search</span></div>
<div id="kb"></div>
<div class="row ch" id="chips"></div>
<div class="tabs" id="tabs"></div>
<div id="sres"></div>
</div>
<div id="lib">
<h1 id="lt"></h1>
<div class="grid" id="lgrid"></div>
</div>
<aside id="side">
<div class="brand"><img id="mark" alt="Vyla"><span>Vyla</span></div>
<nav id="nav"></nav>
</aside>
<div id="ov"></div>
<div id="pl">
<video id="pbg" playsinline preload="auto" muted></video>
<video id="pv" playsinline preload="auto"></video>
<div class="top" id="ptop"></div>
<div class="f" id="pmid"></div>
<div id="pact">
<div class="skip f" id="pskip">Skip Intro</div>
<div class="skip f" id="pnext"></div>
</div>
<div class="bot" id="pbot"></div>
<div id="pep"></div>
<div id="pspin"></div>
<div id="pm"></div>
</div>
<script src="js/vyla-local.js"></script>
<script src="js/native.js"></script>
<script src="js/core.js"></script>
<script src="js/skeleton.js"></script>
<script src="js/focus.js"></script>
<script src="js/library.js"></script>
<script src="js/sidebar.js"></script>
<script src="js/browse.js"></script>
<script src="js/search.js"></script>
<script src="js/detail.js"></script>
<script src="js/player.js"></script>
<script src="js/input.js"></script>
<script src="js/main.js"></script>
</body>
</html>

View File

@@ -1,57 +0,0 @@
function hero(it) { clearTimeout(ht); ht = setTimeout(() => applyHero(it), 130) }
async function applyHero(it) {
if (heroIt === it) return; heroIt = it; $('.hc').classList.remove('sl');
const bp = it.backdrop_path || it.poster_path;
xf(bgb, I + 'w780' + bp); xf(hb, I + 'w1280' + bp);
const t = it.title || it.name;
$('#hlogo').innerHTML = `<h1>${t}</h1>`;
$('#hmeta').innerHTML = `<span>&#9733; ${(it.vote_average || 0).toFixed(1)}</span><span>${yr(it)}</span><span class="b">${it._t === 'movie' ? 'Movie' : 'Series'}</span><span>${gn(it)}</span>`;
$('#hov').textContent = it.overview || '';
const l = await logo(it);
if (heroIt === it && l) $('#hlogo').innerHTML = `<img src="${l}" alt="${t.replace(/"/g, '')}">`
}
function card(it, h = true) {
const c = document.createElement('div'); c.className = 'card f'; c.tabIndex = -1;
c.innerHTML = `<img loading="lazy" src="${I}w342${it.poster_path}" alt="">`;
if (h) c._it = it; c._a = () => openDetail(it); return c
}
function mkRow(i) {
const mv = mode === "movie" || (mode === "discover" && i % 2 === 0), k = mode === "discover" ? i >> 1 : i, list = mv ? MG : TG, g = list[k % list.length], pg = 1 + Math.floor(k / list.length) % 20;
const sec = document.createElement('section'); sec.className = 'sec';
sec.innerHTML = `<h2>${g.name} ${mv ? 'Movies' : 'TV Shows'}</h2><div class="row">${SK.cards(14)}</div>`;
rowsEl.appendChild(sec);
return api(`/discover/${mv ? 'movie' : 'tv'}`, `&with_genres=${g.id}&sort_by=popularity.desc&vote_count.gte=100&include_adult=false&page=${pg}`).then(d => {
const rs = (d.results || []).filter(x => x.poster_path);
if (!rs.length) { sec.remove(); return }
sec.lastChild.innerHTML = ''; rs.forEach(x => { x._t = mv ? 'movie' : 'tv'; sec.lastChild.appendChild(card(x)) })
})
}
async function more(n) {
if (busy) return; busy = true; const g = gen;
const ps = []; for (let j = 0; j < n; j++)ps.push(mkRow(idx++));
await Promise.all(ps); if (g === gen) busy = false
}
async function go(k) {
if (k === 'search') { closeLib(true); openSearch(); return }
closeSearch(true);
if (k === 'wl' || k === 'fav') { openLib(k); return }
closeLib(true);
if (k === mode && rowsEl.children.length) { setAct(k); setFocus(document.querySelector('#rows .card')); return }
mode = k; setAct(k); gen++; busy = false; idx = 0; heroIt = null; lastMain = null; rowsEl.innerHTML = ''; main.scrollTop = 0; $('.hc').classList.add('sl');
await more(6);
const first = document.querySelector('#rows .card');
if (first) { applyHero(first._it); setFocus(first) }
}
main.addEventListener('scroll', () => {
const h = innerHeight;
$('#dim2').style.opacity = Math.min(main.scrollTop / (h * .6), 1) * .55;
if (main.scrollTop + h * 2.5 > main.scrollHeight) more(4)
});
$('#hplay')._a = () => openPlayer(heroIt);
$('#hinfo')._a = () => heroIt && openDetail(heroIt);

View File

@@ -1,37 +0,0 @@
const API_BASE = window.API_BASE || localStorage.getItem('vyla.api') || '';
const K = '3f6703f3e2a0b2b40fe4fbb3e1546244', A = 'https://api.themoviedb.org/3', I = 'https://image.tmdb.org/t/p/';
const $ = s => document.querySelector(s);
const main = $('#main'), rowsEl = $('#rows'), side = $('#side'), ov = $('#ov'), bgb = $('#bg'), hb = $('#hb'), sr = $('#sr'), sres = $('#sres'), chips = $('#chips'), tabs = $('#tabs'), kb = $('#kb'), pl = $('#pl'), vd = $('#pv');
const api = async (p, q = '') => { try { const r = await fetch(`${A}${p}?api_key=${K}&language=en-US${q}`); return r.ok ? await r.json() : {} } catch (e) { return {} } };
const ic = p => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
const P_WL = '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>', P_FV = '<path d="M20.8 5.6a5.5 5.5 0 0 0-7.8 0L12 6.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1 1.1L12 22l7.8-7.5 1-1.1a5.5 5.5 0 0 0 0-7.8z"/>';
const LN = { wl: 'Watch Later', fav: 'Favorites' };
const NAVEL = {}; let navAct = null, mode = '', gen = 0, sgen = 0, st;
let fast = false, G = {}, MG = [], TG = [], cur = null, lastMain = null, heroIt = null, idx = 0, busy = false, ht;
const LC = {};
function xf(box, url, cls = 'xf') {
if (box._u === url) return; box._u = url;
const im = new Image();
im.onload = () => { if (box._u !== url) return; const d = document.createElement('div'); d.className = cls; d.style.backgroundImage = `url(${url})`; box.appendChild(d); requestAnimationFrame(() => requestAnimationFrame(() => d.classList.add('in'))); setTimeout(() => { while (box.children.length > 2) box.firstChild.remove() }, 800) };
im.onerror = () => { if (box._u === url) box._u = null };
im.src = url
}
async function logo(it) {
const k = it._t + it.id; if (k in LC) return LC[k];
const d = await api(`/${it._t}/${it.id}/images`, '&include_image_language=en,null');
const l = (d.logos || []).find(x => x.iso_639_1 === 'en') || (d.logos || [])[0];
return LC[k] = l ? I + 'w500' + l.file_path : null
}
const yr = it => ((it.release_date || it.first_air_date || '') + '').slice(0, 4);
const gn = it => (it.genre_ids || []).map(i => G[i]).filter(Boolean).slice(0, 2).join(', ');
const esc = t => String(t).replace(/</g, '&lt;');
const mkf = (cls, html, fn) => { const e = document.createElement('div'); e.className = cls + ' f'; e.tabIndex = -1; e.innerHTML = html; e._a = fn; return e };
const LOGO = 'assets/favicon.ico', PH = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" rx="11" fill="#2a2a34"/><circle cx="14" cy="14" r="3" fill="#5b5b6b"/><path d="M7 31l9-10 6 6 4-4 7 8z" fill="#5b5b6b"/></svg>');
function toast(m) { const t = document.createElement('div'); t.className = 'toast'; t.textContent = m; (document.fullscreenElement || document.body).appendChild(t); setTimeout(() => t.remove(), 2300) }
const imgDone = e => { const t = e.target; if (t.tagName === 'IMG') { t.classList.add('ld'); t.parentElement && t.parentElement.classList.add('lded') } };
document.addEventListener('load', imgDone, true); document.addEventListener('error', imgDone, true);

View File

@@ -1,66 +0,0 @@
let dgen = 0;
async function openDetail(it) {
const t = it._t;
if (!ov.classList.contains('show')) ov._prev = cur;
ov.classList.add('show'); side.classList.remove('open'); const my = ++dgen; ov.innerHTML = `<div class="pic f" id="dback">${ic('<path d="M19 12H5M12 19l-7-7 7-7"/>')}</div>` + SK.detail(it); $('#dback')._a = closeDetail; ov.scrollTop = 0;
const d = await api(`/${t}/${it.id}`, `&append_to_response=videos,credits,recommendations,images,${t === 'movie' ? 'release_dates' : 'content_ratings'}&include_image_language=en,null`);
if (my !== dgen || !ov.classList.contains('show')) return;
if (!d.id) { closeDetail(); return }
const title = d.title || d.name, year = ((d.release_date || d.first_air_date) || '').slice(0, 4);
const cert = t === 'movie' ? (((d.release_dates || {}).results || []).find(r => r.iso_3166_1 === 'US') || { release_dates: [] }).release_dates.map(x => x.certification).find(Boolean) : (((d.content_ratings || {}).results || []).find(r => r.iso_3166_1 === 'US') || {}).rating;
const rt = t === 'movie' ? (d.runtime ? `${Math.floor(d.runtime / 60)}h ${d.runtime % 60}m` : '') : (d.number_of_seasons ? `${d.number_of_seasons} Season${d.number_of_seasons > 1 ? 's' : ''}` : '');
const lg = ((d.images || {}).logos || []).find(x => x.iso_639_1 === 'en') || ((d.images || {}).logos || [])[0];
const bp = d.backdrop_path || d.poster_path;
ov.innerHTML = `<div class="pic f" id="dback">${ic('<path d="M19 12H5M12 19l-7-7 7-7"/>')}</div><div class="dbl" style="background-image:url(${I}w780${bp})"></div><div class="dw"><div class="dimg" style="background-image:url(${I}w1280${bp})"></div><div class="dc"><div class="dh"><div id="dlogo">${lg ? `<img src="${I}w500${lg.file_path}" alt="">` : `<h1>${title}</h1>`}</div><div class="meta"><span>${(d.vote_average || 0).toFixed(1)}</span><span>${year}</span>${cert ? `<span class="b">${cert}</span>` : ''}<span>${rt}</span><span>${(d.genres || []).slice(0, 3).map(g => g.name).join(', ')}</span></div><div class="hb"><button class="pb f" id="dplay">Play</button><div class="circ f" id="dmenu">&#8942;</div><div class="mx" id="dmx"><div class="circ f" id="dwl"></div><div class="circ f" id="dfv"></div></div></div><p class="otx" id="otx">${d.overview || ''}</p><span class="more f" id="dmore">MORE</span></div><div id="dsecs"></div></div></div>`;
$('#dback')._a = closeDetail; const secs = $('#dsecs');
const rowOf = (name, kids) => { if (!kids.length) return; const s = document.createElement('section'); s.className = 'ds'; s.innerHTML = `<h3>${name}</h3><div class="row"></div>`; s.lastChild.append(...kids); secs.appendChild(s) };
const el = (c, h) => { const e = document.createElement('div'); e.className = c + ' f'; e.tabIndex = -1; e.innerHTML = h; e._a = () => { }; return e };
if (t === 'tv') {
const ss = (d.seasons || []).filter(x => x.season_number > 0 && x.episode_count);
if (ss.length) {
const sec = document.createElement('section'); sec.className = 'ds eps';
sec.innerHTML = '<div class="eh"><h3>Episodes</h3><div class="dd"><div class="ddb f" id="ddb"></div><div class="ddl" id="ddl"></div></div></div><div class="el" id="el"></div>';
secs.appendChild(sec);
const ddb = $('#ddb'), ddl = $('#ddl'), list = $('#el'); let cs = null;
const nm = x => x.name || 'Season ' + x.season_number;
const loadEps = async x => {
cs = x; ddb.innerHTML = `<span>${esc(nm(x))}</span>` + ic('<path d="m6 9 6 6 6-6"/>');
[...ddl.children].forEach((c, i) => c.classList.toggle('cur', ss[i] === x));
list.innerHTML = SK.eps(10);
const r = await api(`/tv/${it.id}/season/${x.season_number}`);
if (cs !== x || !list.isConnected) return;
list.innerHTML = '';
(r.episodes || []).forEach((p, ix) => {
const n = p.episode_number, img = p.still_path ? I + 'w300' + p.still_path : (it.backdrop_path ? I + 'w300' + it.backdrop_path : PH);
const e = el('ep', `<div class="et"><img loading="lazy" src="${img}" alt=""></div><b>${esc(p.name || 'Episode ' + n)}</b><small>${['Episode ' + n, p.runtime ? p.runtime + 'm' : ''].filter(Boolean).join(' \u2022 ')}</small>`);
e.title = p.overview || '';
e._a = () => openPlayer(it, `S${x.season_number} E${n} \u2022 ${p.name || ''}`, { it, seasons: ss, s: x.season_number, eps: r.episodes, i: ix, cache: { [x.season_number]: r.episodes } }); list.appendChild(e)
});
const f0 = list.firstChild; if (f0 && f0._a && $('#dplay')) $('#dplay')._a = () => f0._a();
if (!list.children.length) list.innerHTML = '<p class="none">No episodes listed.</p>'
};
ss.forEach(x => ddl.appendChild(mkf('ddi', `<span>${esc(nm(x))}</span><small>${x.episode_count} eps</small>`, () => { closeDD(); loadEps(x) })));
ddb._a = () => { if (!closeDD()) { ddl.classList.add('open'); setFocus(ddl.children[Math.max(0, ss.indexOf(cs))]) } };
loadEps(ss[0])
}
}
rowOf('Trailers', ((d.videos || {}).results || []).filter(v => v.site === 'YouTube' && /Trailer|Teaser|Clip/.test(v.type)).sort((a, b) => (b.type === 'Trailer') - (a.type === 'Trailer')).slice(0, 8).map(v => el('tr', `<div class="tb" style="background-image:url(https://img.youtube.com/vi/${v.key}/mqdefault.jpg)"></div><div class="th" style="background-image:url(https://img.youtube.com/vi/${v.key}/mqdefault.jpg)"></div><b>${v.name}</b><small>&#9654; ${v.type}</small>`)));
rowOf('Related', ((d.recommendations || {}).results || []).filter(x => x.poster_path).map(x => { x._t = t; return card(x, false) }));
rowOf('Cast', ((d.credits || {}).cast || []).slice(0, 20).map(p => el('cs', `<div class="pf" style="${p.profile_path ? `background-image:url(${I}w185${p.profile_path})` : ''}">${p.profile_path ? '' : p.name[0]}</div><b>${p.name}</b><small>${p.character || ''}</small>`)));
$('#dplay')._a = () => openPlayer(it); const sync = () => [['dwl', 'wl', P_WL], ['dfv', 'fav', P_FV]].forEach(([id, k, p]) => { const e = $('#' + id), v = has(k, it); e.classList.toggle('sv', v); e.dataset.l = (v ? 'Remove from ' : 'Add to ') + LN[k]; e.innerHTML = ic(p) });
sync();
$('#dmenu')._a = () => { const o = $('#dmx').classList.toggle('open'); $('#dmenu').classList.toggle('sv', o) };
$('#dwl')._a = () => { toggleLib('wl', it); sync() }; $('#dfv')._a = () => { toggleLib('fav', it); sync() };
$('#dmore')._a = () => { const o = $('#otx'), m = $('#dmore'); o.classList.toggle('x'); m.textContent = o.classList.contains('x') ? 'LESS' : 'MORE' };
ov.scrollTop = 0;
setFocus($('#dplay'))
}
function closeDetail() {
ov.classList.remove('show');
const p = ov._prev;
setTimeout(() => { if (!ov.classList.contains('show')) ov.innerHTML = '' }, 400);
if (p && document.body.contains(p)) setFocus(p); else if (lib.classList.contains('show')) setFocus($('#lgrid .card') || navAct)
}
function closeDD() { const l = $('#ddl'); if (l && l.classList.contains('open')) { l.classList.remove('open'); setFocus($('#ddb')); return true } return false }

View File

@@ -1,60 +0,0 @@
function setFocus(el) {
if (!el) return;
const od = $('#ddl.open'); if (od && !el.closest('.dd')) od.classList.remove('open');
cur && cur.classList.remove('on'); cur = el; el.classList.add('on');
const inSide = !!el.closest('#side'), inOv = !!el.closest('#ov');
side.classList.toggle('open', inSide && !ov.classList.contains('show'));
if (!inSide && !inOv && !el.closest('#pl')) lastMain = el;
const row = el.closest('.row');
if (row) row.scrollTo({ left: Math.max(0, el.offsetLeft - parseInt(getComputedStyle(row).paddingLeft)), behavior: fast ? 'auto' : 'smooth' });
const menu = el.closest('#pm');
if (menu) {
el.scrollIntoView({ block: 'nearest', behavior: fast ? 'auto' : 'smooth' });
return;
}
const scr = el.closest('#ov,#sr,#lib');
if (scr && el.closest('#kb,#chips,#tabs')) scr.scrollTo({ top: 0, behavior: fast ? 'auto' : 'smooth' });
else if (scr) {
const r = el.getBoundingClientRect();
if (r.bottom > innerHeight - 50) scr.scrollBy({ top: r.bottom - innerHeight + 110, behavior: fast ? 'auto' : 'smooth' });
else if (r.top < 50) scr.scrollBy({ top: r.top - 110, behavior: fast ? 'auto' : 'smooth' });
}
else if (!inSide && !el.closest('#pl')) {
const sec = el.closest('.sec');
main.scrollTo({ top: sec ? Math.max(0, sec.offsetTop - innerHeight * .52) : 0, behavior: fast ? 'auto' : 'smooth' });
}
if (el._it) hero(el._it); if (el._bg) sbgf(el._bg);
}
function nav(d) {
const sc = pl.classList.contains('show') ? (pl.classList.contains('eps') ? '#pep .f' : '#pl .f') : ov.classList.contains('show') ? '#ov .f' : sr.classList.contains('show') ? '#sr .f,#side .f' : lib.classList.contains('show') ? '#lib .f,#side .f' : '#side .f,#main .f';
const c = cur.getBoundingClientRect(), cx = c.left + c.width / 2, cy = c.top + c.height / 2;
const inSide = !!cur.closest('#side');
if (inSide && d === 'r' && lastMain && getComputedStyle(lastMain).visibility !== 'hidden') { setFocus(lastMain); return }
if ((d === 'l' || d === 'r') && !inSide) {
const g = cur.closest('.row,#kb,#tabs');
if (g) {
const L = [...g.children].filter(e => e.classList.contains('f') && e.getBoundingClientRect().width), i = L.indexOf(cur), n = L[i + (d === 'r' ? 1 : -1)];
if (i >= 0) { if (n) setFocus(n); else if (d === 'l' && !cur.closest('#ov,#pl')) setFocus(navAct); return }
}
}
let best = null, bs = 1e9;
document.querySelectorAll(sc).forEach(e => {
if (e === cur) return;
const r = e.getBoundingClientRect(); if (!r.width) return;
const x = r.left + r.width / 2, y = r.top + r.height / 2, dx = x - cx, dy = y - cy;
let p, s;
if (d === 'r') { if (dx < 8) return; p = dx; s = Math.abs(dy) * 2.5 }
else if (d === 'l') { if (dx > -8) return; p = -dx; s = Math.abs(dy) * 2.5 }
else if (d === 'd') { if (dy < 8) return; p = dy; s = Math.abs(dx) * 2 }
else { if (dy > -8) return; p = -dy; s = Math.abs(dx) * 2 }
if (p + s < bs) { bs = p + s; best = e }
});
if (!best) return;
if (best.closest('#side') && !inSide) best = navAct;
setFocus(best)
}
function act(el) { el && el._a && el._a() }

View File

@@ -1,112 +0,0 @@
if (window.tizen && window.tizen.tvinputdevice) {
try {
['MediaPlay', 'MediaPause', 'MediaPlayPause', 'MediaStop', 'MediaFastForward', 'MediaRewind', '10009']
.forEach(k => tizen.tvinputdevice.registerKey(k));
} catch (_) { }
}
const KM = {
37: 'l', 21: 'l',
38: 'u', 19: 'u',
39: 'r', 22: 'r',
40: 'd', 20: 'd',
13: 'e', 23: 'e', 66: 'e',
27: 'b', 8: 'b', 4: 'b', 10009: 'b', 461: 'b'
};
document.addEventListener('keydown', e => {
fast = e.repeat;
document.body.classList.add('kbm');
if (e.keyCode === 85 || e.keyCode === 179 || e.keyCode === 10252 || e.keyCode === 415) {
e.preventDefault();
tgl();
return;
}
if (e.keyCode === 126 || e.keyCode === 250) {
e.preventDefault();
vd.play();
return;
}
if (e.keyCode === 127 || e.keyCode === 413) {
e.preventDefault();
vd.pause();
return;
}
if (e.keyCode === 89 || e.keyCode === 227 || e.keyCode === 412) {
e.preventDefault();
vd.currentTime = Math.max(0, vd.currentTime - 10);
return;
}
if (e.keyCode === 90 || e.keyCode === 228 || e.keyCode === 417) {
e.preventDefault();
vd.currentTime = Math.min(vd.duration || Infinity, vd.currentTime + 10);
return;
}
if (pl.classList.contains('show')) {
showCtl();
if (e.key === ' ' || e.keyCode === 32) { e.preventDefault(); tgl(); return }
if (cur === $('#pseek') && (e.keyCode === 37 || e.keyCode === 39 || e.keyCode === 21 || e.keyCode === 22)) {
e.preventDefault();
vd.currentTime += (e.keyCode === 39 || e.keyCode === 22) ? 10 : -10;
return;
}
if (cur && cur.id === 'vsl' && (e.keyCode === 37 || e.keyCode === 39 || e.keyCode === 21 || e.keyCode === 22)) {
e.preventDefault();
vd.muted = false;
vd.volume = Math.min(1, Math.max(0, vd.volume + ((e.keyCode === 39 || e.keyCode === 22) ? .1 : -.1)));
cur._v();
return;
}
}
if (sr.classList.contains('show') && !ov.classList.contains('show') && !pl.classList.contains('show')) {
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); setQ(sqv + e.key); return }
if (e.keyCode === 8) { e.preventDefault(); setQ(sqv.slice(0, -1)); return }
}
const k = KM[e.keyCode];
if (!k) return;
e.preventDefault();
if (!cur) {
const fallback = document.querySelector('.f');
if (fallback) setFocus(fallback);
return;
}
if (k === 'e') act(cur);
else if (k === 'b') {
if (pl.classList.contains('show')) {
pmK ? closeMenu(1) : pl.classList.contains('eps') ? closeEps(1) : closePlayer();
} else if (ov.classList.contains('show')) {
closeDD() || closeDetail();
} else if (lib.classList.contains('show')) {
closeLib();
} else if (sr.classList.contains('show')) {
closeSearch();
}
} else {
nav(k);
}
});
let mx = -1, my = -1;
document.addEventListener('mousemove', e => {
if (Math.abs(e.clientX - mx) + Math.abs(e.clientY - my) < 4) return;
mx = e.clientX; my = e.clientY; document.body.classList.remove('kbm');
if (pl.classList.contains('show')) { showCtl(); const g = e.target.closest('.f'); if (g && g !== cur && pl.contains(g)) setFocus(g); return }
const f = e.target.closest('.f');
if (f && f !== cur && (!ov.classList.contains('show') || ov.contains(f))) setFocus(f)
});
document.addEventListener('click', e => {
const f = e.target.closest('.f'); if (f) act(f)
});
ov.addEventListener('wheel', () => { }, { passive: true });
window.vylaBack = () => {
const open = [pl, ov, lib, sr].some(e => e.classList.contains('show'));
if (open) document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 4, key: 'GoBack', bubbles: true, cancelable: true }));
return open;
};

View File

@@ -1,27 +0,0 @@
let LIB = { wl: [], fav: [] }, libK = null; const lib = $('#lib');
try { Object.assign(LIB, JSON.parse(localStorage.getItem('vyla.lib') || '{}')) } catch (e) { }
const saveLib = () => { try { localStorage.setItem('vyla.lib', JSON.stringify(LIB)) } catch (e) { } };
const slim = it => ({ id: it.id, _t: it._t || it.media_type, title: it.title, name: it.name, poster_path: it.poster_path, backdrop_path: it.backdrop_path, release_date: it.release_date, first_air_date: it.first_air_date, vote_average: it.vote_average, overview: it.overview });
const has = (k, it) => LIB[k].some(x => x.id === it.id && x._t === it._t);
function toggleLib(k, it) {
const i = LIB[k].findIndex(x => x.id === it.id && x._t === it._t), had = i >= 0;
if (had) LIB[k].splice(i, 1); else LIB[k].unshift(slim(it));
saveLib(); syncNav(); toast((had ? 'Removed from ' : 'Added to ') + LN[k]);
if (lib.classList.contains('show')) drawLib()
}
function drawLib() {
const k = libK, a = LIB[k], g = $('#lgrid');
$('#lt').innerHTML = `${LN[k]}<small>${a.length} title${a.length === 1 ? '' : 's'}</small>`;
g.innerHTML = '';
if (!a.length) { g.innerHTML = '<p class="none">Nothing here yet. Open a title, press the \u22EE button and add it.</p>'; return }
a.forEach(x => { const c = card(x, false); c._bg = x; g.appendChild(c) })
}
function openLib(k) {
libK = k; setAct(k); document.body.classList.add('lib'); lib.classList.add('show'); side.classList.remove('open'); lib.scrollTop = 0;
drawLib(); syncNav(); setFocus($('#lgrid .card') || NAVEL[k])
}
function closeLib(silent) {
if (!lib.classList.contains('show')) return;
lib.classList.remove('show'); document.body.classList.remove('lib'); libK = null; heroIt = null; bgb._u = null; syncNav();
if (!silent) { setAct(mode); setFocus($('#rows .card')) }
}

View File

@@ -1,8 +0,0 @@
(async () => {
rowsEl.innerHTML = SK.rows(4);
const [m, t] = await Promise.all([api('/genre/movie/list'), api('/genre/tv/list')]);
MG = m.genres || []; TG = t.genres || [];
[...MG, ...TG].forEach(g => G[g.id] = g.name);
if (!MG.length || !TG.length) { rowsEl.innerHTML = '<p style="padding:40vh var(--pad)">Could not reach TMDB.</p>'; return }
await go('discover');
})();

View File

@@ -1,112 +0,0 @@
(() => {
const cap = window.Capacitor || {};
const N = (cap.Plugins && cap.Plugins.VylaNative) || cap.registerPlugin('VylaNative');
window.API_BASE = 'http://127.0.0.1:38417';
const webFetch = window.fetch.bind(window);
const dec = new TextDecoder('utf-8');
const bytesOf = s => { const b = atob(s), u = new Uint8Array(b.length); for (let i = 0; i < b.length; i++) u[i] = b.charCodeAt(i); return u };
const abortErr = () => new DOMException('The operation was aborted', 'AbortError');
const wrap = r => {
const bytes = bytesOf(r.body || ''), h = r.headers || {};
const text = async () => dec.decode(bytes);
return {
ok: r.status >= 200 && r.status < 300,
status: r.status,
statusText: '',
url: r.url,
headers: { get: k => { const v = h[String(k).toLowerCase()]; return v === undefined ? null : v }, has: k => String(k).toLowerCase() in h },
body: { getReader: () => { let d = false; return { read: async () => d ? { done: true, value: undefined } : (d = true, { done: false, value: bytes }) } }, cancel: async () => { } },
text,
json: async () => JSON.parse(await text()),
arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
};
};
window.fetch = (input, init = {}) => {
const isReq = typeof input !== 'string';
const url = (isReq ? (input && input.url) : input) || '';
if (!/^https?:\/\//i.test(url) || url.startsWith(window.API_BASE) || /^https:\/\/(api|image)\.themoviedb\.org|^https:\/\/image\.tmdb\.org/i.test(url)) return webFetch(input, init);
const hd = {};
new Headers(init.headers || (isReq ? input.headers : undefined)).forEach((v, k) => { hd[k] = v });
const sig = init.signal;
if (sig && sig.aborted) return Promise.reject(abortErr());
const call = N.fetch({
url,
method: String(init.method || (isReq ? input.method : 'GET')).toUpperCase(),
headers: hd,
body: init.body == null ? undefined : String(init.body),
timeout: init.timeout || 120000,
redirect: init.redirect || 'follow',
maxBytes: init.maxBytes || 0
}).then(wrap);
if (!sig) return call;
return Promise.race([call, new Promise((_, rej) => sig.addEventListener('abort', () => rej(abortErr()), { once: true }))]);
};
let booted = false;
const boot = () => { if (!booted) { VylaLocal.init(K); booted = true } };
const opt = v => v === undefined || v === null || v === '' || v === 'null' || v === 'undefined' ? undefined : String(v);
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
const OKS = [200, 206, 304];
const SEG = { recap: 'Skip Recap', intro: 'Skip Intro', preview: 'Skip Preview', credits: 'Skip Credits' };
const probeGet = (u, h, max) => fetch(u, { headers: { 'User-Agent': UA, Accept: '*/*', 'Accept-Language': 'en-US,en;q=0.9', ...h, Range: 'bytes=0-1024' }, timeout: 6000, maxBytes: max });
const playable = async (url, h, depth = 0) => {
try {
const r = await probeGet(url, h, 1048576);
if (!OKS.includes(r.status)) return false;
const ct = (r.headers.get('content-type') || '').toLowerCase();
if (!(url.includes('.m3u8') || ct.includes('mpegurl') || ct.includes('m3u8'))) return true;
if (depth > 4) return false;
const line = (await r.text()).split('\n').map(x => x.trim()).find(x => x && x[0] !== '#');
if (!line) return false;
const base = url.slice(0, url.lastIndexOf('/') + 1), origin = new URL(url).origin;
const t = line.startsWith('http') ? line : line.startsWith('/') ? origin + line : base + line;
if (t.includes('m3u8')) return playable(t, h, depth + 1);
return OKS.includes((await probeGet(t, h, 1024)).status);
} catch (_) { return false }
};
window.Vyla = {
sources() {
boot();
return VylaLocal.sources().map(c => ({ key: c.key, label: c.label }));
},
async stream(provider, tmdbId, season, episode) {
boot();
try {
const d = await VylaLocal.stream(String(provider), String(tmdbId), opt(season), opt(episode));
if (!d) return { success: false };
const h = d.headers || {};
if (Array.isArray(d.allUrls) && d.allUrls.length) {
const ok = await Promise.all(d.allUrls.map(async u => (u && u.url && await playable(u.url, h)) ? u : null));
d.allUrls = ok.filter(Boolean);
if (d.allUrls.length) { if (!d.url || !d.allUrls.some(u => u.url === d.url)) d.url = d.allUrls[0].url }
else d.url = null;
} else if (d.url && !(await playable(d.url, h))) d.url = null;
return d.url ? { success: true, provider, stream: d } : { success: false };
} catch (_) { return { success: false } }
},
async subtitles(id, season, episode) {
boot();
try { return (await VylaLocal.subtitles(String(id), opt(season), opt(episode))) || [] } catch (_) { return [] }
},
async timestamps(id, season, episode, durationSec) {
let u = `https://api.theintrodb.org/v3/media?tmdb_id=${id}`;
if (season) u += `&season=${season}`;
if (episode) u += `&episode=${episode}`;
if (durationSec) u += `&duration_ms=${Math.round(durationSec * 1000)}`;
try {
const r = await fetch(u, { timeout: 8000 });
if (!r.ok) return [];
const d = await r.json(), out = [];
Object.keys(SEG).forEach(t => (Array.isArray(d[t]) ? d[t] : []).forEach(x => out.push({ type: t, label: SEG[t], start: x.start_ms != null ? x.start_ms / 1000 : 0, end: x.end_ms != null ? x.end_ms / 1000 : null })));
return out;
} catch (_) { return [] }
}
};
})();

View File

@@ -1,794 +0,0 @@
let currentHls = null;
let currentBgHls = null;
let currentSources = [];
let resolvedStreams = [];
let selectedSourceKey = null;
let selectedStreamUrl = null;
let expandedSources = new Set();
let currentQualityLevels = [];
let selectedQualityIndex = -1;
let activeSubtitles = [];
let mediaSegments = [];
let activeSegment = null;
let plPrev = null;
let plT = null;
let plItem = null;
let pmK = null;
let plCtx = null;
let viewS = null;
let drag = 0;
let discoverySession = 0;
let userHasManuallySelectedSource = false;
let subSettings = {
size: 24,
color: '#ffffff',
bg: 'rgba(0,0,0,0.75)',
offset: 0
};
const pi = (p, f) => `<svg viewBox="0 0 24 24" fill="${f ? 'currentColor' : 'none'}" stroke="${f ? 'none' : 'currentColor'}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
const SPK = '<path d="M4 9v6h4l5 4V5L8 9z"/>';
const PI = {
x: pi('<path d="M5 5l14 14M19 5 5 19"/>'),
play: pi('<path d="M7 4.5v15L19.5 12z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>', 1),
pause: pi('<rect x="6" y="4.5" width="4" height="15" rx="1.2"/><rect x="14" y="4.5" width="4" height="15" rx="1.2"/>', 1),
cc: pi('<rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10.5 10a2 2 0 1 0 0 4M17 10a2 2 0 1 0 0 4"/>'),
vol: pi(SPK + '<path d="M16.5 8.5a5 5 0 0 1 0 7M19 6a9 9 0 0 1 0 12"/>'),
mute: pi(SPK + '<path d="m17 9.5 4 5m0-5-4 5"/>'),
qlt: pi('<path d="M4 6h16M4 12h16M4 18h7"/>'),
set: pi('<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>'),
more: pi('<path d="M5 12h.01M12 12h.01M19 12h.01" stroke-width="3.4"/>'),
fs: pi('<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>'),
fx: pi('<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/>'),
eps: pi('<rect x="3" y="4" width="7" height="6" rx="1.5"/><rect x="14" y="4" width="7" height="6" rx="1.5"/><rect x="3" y="14" width="7" height="6" rx="1.5"/><rect x="14" y="14" width="7" height="6" rx="1.5"/>'),
chk: pi('<path d="m5 12.5 4.5 4.5L19 7"/>')
};
$('#ptop').innerHTML = `<div class="pic f" id="pback">${PI.x}</div><img class="av" id="pav" alt=""><div class="pt"><b id="pt"></b><small id="ps"></small></div><div class="pr"><div class="pic f" id="pqlt" title="Quality">${PI.qlt}</div><div class="pic f" id="pcc">${PI.cc}</div><div class="pic f" id="pvol">${PI.vol}</div><div class="pic f" id="pset">${PI.set}</div><div class="pic f" id="pmore">${PI.more}</div></div>`;
$('#pbot').innerHTML = `<span class="bt" id="pcur">0:00</span><div class="sk f" id="pseek"><u id="pbuf"></u><i id="pfill"></i><b id="phd"></b></div><span class="bt" id="pdur">0:00</span><div class="pic f" id="peps">${PI.eps}</div><div class="pic f" id="pfs">${PI.fs}</div>`;
$('#pmid').innerHTML = PI.play;
const pm = $('#pm');
const sk = $('#pseek');
const pbg = $('#pbg');
const fmt = t => {
t = Math.max(0, Math.floor(t || 0));
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const c = t % 60;
return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(c).padStart(2, '0');
};
const tgl = () => vd.paused ? vd.play() : vd.pause();
const pico = () => {
$('#pmid').innerHTML = vd.paused ? PI.play : PI.pause;
};
function showCtl() {
pl.classList.add('ui');
clearTimeout(plT);
plT = setTimeout(() => {
if (!vd.paused && !pmK && !pl.classList.contains('eps')) {
pl.classList.remove('ui');
}
}, 3800);
}
function closeMenu(rf) {
if (!pmK) return;
const t = {
qlt: '#pqlt',
cc: '#pcc',
vol: '#pvol',
set: '#pset',
more: '#pmore'
}[pmK];
pmK = null;
pm.classList.remove('open');
pm.innerHTML = '';
if (rf) setFocus($(t));
}
function updateSubStyles() {
vd.classList.add('sub-active');
vd.style.setProperty('--sub-size', `${subSettings.size}px`);
vd.style.setProperty('--sub-color', subSettings.color);
vd.style.setProperty('--sub-bg', subSettings.bg);
Array.from(vd.textTracks).forEach(track => {
if (track.cues) {
for (let i = 0; i < track.cues.length; i++) {
const cue = track.cues[i];
if (cue && !cue._initialOffsetApplied) {
cue.startTime += subSettings.offset;
cue.endTime += subSettings.offset;
}
}
}
});
}
function applySubOffset(delta) {
subSettings.offset += delta;
Array.from(vd.textTracks).forEach(track => {
if (track.cues) {
for (let i = 0; i < track.cues.length; i++) {
const cue = track.cues[i];
if (cue) {
cue.startTime += delta;
cue.endTime += delta;
}
}
}
});
}
function buildProxyUrl(rawUrl, headersObj = {}) {
return `${API_BASE}/api/proxy?url=${encodeURIComponent(rawUrl)}&headers=${encodeURIComponent(JSON.stringify(headersObj))}`;
}
async function fetchSources() {
currentSources = Vyla.sources();
}
async function startConcurrentDiscovery(it, s = null, e = null) {
const session = ++discoverySession;
pl.classList.add('buf');
resolvedStreams = [];
selectedStreamUrl = null;
let foundFirstPlayable = false;
if (!currentSources.length) {
await fetchSources();
}
if (session !== discoverySession) return;
const sourcePromises = currentSources.map(async (src) => {
try {
const data = await Vyla.stream(src.key, it.id, s, e);
if (session !== discoverySession) return;
if (data.success && data.stream) {
const streamData = data.stream;
const headers = streamData.headers || {};
let playable = [];
if (Array.isArray(streamData.allUrls) && streamData.allUrls.length > 0) {
playable = streamData.allUrls;
} else if (streamData.url) {
playable = [{ url: streamData.url, quality: streamData.quality || 'Auto', isM3U8: streamData.isM3U8 }];
}
if (playable.length > 0) {
const resolvedEntry = {
provider: src.key,
label: src.label || src.key,
headers,
primaryUrl: playable[0].url,
allUrls: playable
};
resolvedStreams.push(resolvedEntry);
if (pmK === 'set') {
renderSourcesMenu();
}
if (!foundFirstPlayable && !userHasManuallySelectedSource) {
foundFirstPlayable = true;
selectedSourceKey = src.key;
playSpecificStream(resolvedEntry.primaryUrl, resolvedEntry.headers);
} else if (userHasManuallySelectedSource && selectedSourceKey === src.key && !selectedStreamUrl) {
playSpecificStream(resolvedEntry.primaryUrl, resolvedEntry.headers);
}
}
}
} catch (_) { }
});
Promise.allSettled(sourcePromises).then(() => {
if (session !== discoverySession) return;
if (!resolvedStreams.length) {
pl.classList.remove('buf');
toast('No playable sources available.');
}
});
loadSubtitles(it.id, s, e);
loadTimestamps(it.id, s, e);
}
function playSpecificStream(streamUrl, headers = {}) {
pl.classList.add('buf');
selectedStreamUrl = streamUrl;
if (currentHls) {
currentHls.destroy();
currentHls = null;
}
if (currentBgHls) {
currentBgHls.destroy();
currentBgHls = null;
}
const finalUrl = buildProxyUrl(streamUrl, headers);
if (streamUrl.includes('.m3u8')) {
if (Hls.isSupported()) {
currentHls = new Hls({
xhrSetup: function (xhr) {
xhr.withCredentials = false;
}
});
currentHls.loadSource(finalUrl);
currentHls.attachMedia(vd);
currentHls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
currentQualityLevels = data.levels || [];
vd.play().catch(() => { });
});
currentHls.on(Hls.Events.LEVEL_SWITCHED, (_, data) => {
selectedQualityIndex = data.level;
});
if (pbg) {
currentBgHls = new Hls({
xhrSetup: function (xhr) {
xhr.withCredentials = false;
}
});
currentBgHls.loadSource(finalUrl);
currentBgHls.attachMedia(pbg);
currentBgHls.on(Hls.Events.MANIFEST_PARSED, () => {
pbg.muted = true;
if (!vd.paused) pbg.play().catch(() => { });
});
}
} else if (vd.canPlayType('application/vnd.apple.mpegurl')) {
vd.src = finalUrl;
vd.play().catch(() => { });
if (pbg) {
pbg.src = finalUrl;
pbg.muted = true;
pbg.play().catch(() => { });
}
}
} else {
currentQualityLevels = [];
vd.src = finalUrl;
vd.play().catch(() => { });
if (pbg) {
pbg.src = finalUrl;
pbg.muted = true;
pbg.play().catch(() => { });
}
}
}
function setPlaybackQuality(levelIndex) {
selectedQualityIndex = levelIndex;
if (currentHls) {
currentHls.currentLevel = levelIndex;
}
}
async function loadSubtitles(id, s, e) {
Array.from(vd.querySelectorAll('track')).forEach(t => t.remove());
activeSubtitles = [];
try {
activeSubtitles = await Vyla.subtitles(id, s, e);
activeSubtitles.forEach((sub, idx) => {
const track = document.createElement('track');
track.kind = 'subtitles';
track.label = `${sub.label} (${sub.source || 'mirror'})`;
track.srclang = sub.label.slice(0, 2).toLowerCase();
track.src = buildProxyUrl(sub.file);
track.default = idx === 0;
vd.appendChild(track);
track.track.mode = 'disabled';
});
updateSubStyles();
} catch (_) { }
}
async function loadTimestamps(tmdbId, season = null, episode = null, durationSec = null) {
mediaSegments = [];
activeSegment = null;
$('#pskip').classList.remove('v');
try {
let q = `${API_BASE}/api/timestamps?tmdb_id=${tmdbId}`;
if (season) q += `&season=${season}`;
if (episode) q += `&episode=${episode}`;
if (durationSec) q += `&duration_ms=${Math.round(durationSec * 1000)}`;
const res = await fetch(q);
const data = await res.json();
mediaSegments = data.segments || [];
} catch (_) {
mediaSegments = [];
}
}
function renderSourcesMenu() {
let container = $('#pm-src-list');
if (!container) return;
container.innerHTML = '';
if (!resolvedStreams.length) {
container.innerHTML = '<div style="padding:10px 14px;color:rgba(255,255,255,0.4);font-size:13px;">Probing providers...</div>';
return;
}
resolvedStreams.forEach(s => {
const grp = document.createElement('div');
grp.className = 'src-group';
const hdr = document.createElement('div');
hdr.className = 'src-header f' + (s.provider === selectedSourceKey ? ' on' : '');
const hasMulti = s.allUrls && s.allUrls.length > 1;
const countLabel = hasMulti ? ` (${s.allUrls.length})` : '';
const expandIcon = hasMulti ? `<span class="src-expand-btn">${expandedSources.has(s.provider) ? '▲' : '▼'}</span>` : '';
hdr.innerHTML = `<span>${s.label}${countLabel}</span><div>${expandIcon}${s.provider === selectedSourceKey ? PI.chk : ''}</div>`;
hdr._a = () => {
userHasManuallySelectedSource = true;
if (hasMulti && s.provider === selectedSourceKey) {
if (expandedSources.has(s.provider)) expandedSources.delete(s.provider);
else expandedSources.add(s.provider);
renderSourcesMenu();
} else {
selectedSourceKey = s.provider;
playSpecificStream(s.primaryUrl, s.headers);
renderSourcesMenu();
}
};
grp.appendChild(hdr);
if (hasMulti) {
const sub = document.createElement('div');
sub.className = 'src-sublist' + (expandedSources.has(s.provider) ? ' open' : '');
s.allUrls.forEach((u, i) => {
const item = document.createElement('div');
item.className = 'src-subitem f' + (u.url === selectedStreamUrl ? ' on' : '');
item.innerHTML = `<span>${u.quality || 'Stream ' + (i + 1)}</span>${u.url === selectedStreamUrl ? PI.chk : ''}`;
item._a = () => {
userHasManuallySelectedSource = true;
selectedSourceKey = s.provider;
playSpecificStream(u.url, s.headers);
renderSourcesMenu();
};
sub.appendChild(item);
});
grp.appendChild(sub);
}
container.appendChild(grp);
});
}
function openMenu(k) {
if (pmK === k) {
closeMenu(1);
return;
}
closeEps();
pm.innerHTML = '';
pmK = k;
pm.classList.add('open');
let first = null;
const h = t => {
const e = document.createElement('h4');
e.textContent = t;
pm.appendChild(e);
};
const r = (l, on, fn) => {
const e = mkf('pi', `<span>${l}</span>${on ? PI.chk : ''}`, () => {
fn();
closeMenu(1);
});
pm.appendChild(e);
if (on && !first) first = e;
return e;
};
if (k === 'qlt') {
h('Stream Quality');
if (currentHls && currentQualityLevels.length > 0) {
r('Auto', selectedQualityIndex === -1, () => setPlaybackQuality(-1));
currentQualityLevels.forEach((lvl, idx) => {
const label = lvl.height ? `${lvl.height}p` : `${Math.round(lvl.bitrate / 1000)} kbps`;
r(label, selectedQualityIndex === idx, () => setPlaybackQuality(idx));
});
} else {
const activeProv = resolvedStreams.find(s => s.provider === selectedSourceKey);
if (activeProv && activeProv.allUrls.length > 1) {
activeProv.allUrls.forEach((u, idx) => {
const label = u.quality || `Stream ${idx + 1}`;
r(label, u.url === selectedStreamUrl, () => {
userHasManuallySelectedSource = true;
playSpecificStream(u.url, activeProv.headers);
});
});
} else {
r('Auto', true, () => { });
}
}
} else if (k === 'cc') {
h('Subtitles');
const T = [...vd.textTracks];
r('Off', !T.some(t => t.mode === 'showing'), () => {
T.forEach(t => t.mode = 'disabled');
});
T.forEach(t => r(t.label, t.mode === 'showing', () => {
T.forEach(u => u.mode = u === t ? 'showing' : 'disabled');
updateSubStyles();
}));
h('Subtitle Size');
[18, 22, 26, 32].forEach(sz => {
r(`${sz}px`, subSettings.size === sz, () => {
subSettings.size = sz;
updateSubStyles();
});
});
h('Subtitle Color');
[['White', '#ffffff'], ['Yellow', '#ffd700'], ['Cyan', '#00ffff']].forEach(([col, code]) => {
r(col, subSettings.color === code, () => {
subSettings.color = code;
updateSubStyles();
});
});
h('Subtitle Sync');
const syncRow = document.createElement('div');
syncRow.className = 'sub-ctrl-row';
syncRow.innerHTML = `<span>Timing Offset</span><div class="sub-btn-group"><button id="sub-min">-0.5s</button><button id="sub-plus">+0.5s</button></div>`;
pm.appendChild(syncRow);
$('#sub-min').onclick = (ev) => {
ev.stopPropagation();
applySubOffset(-0.5);
};
$('#sub-plus').onclick = (ev) => {
ev.stopPropagation();
applySubOffset(0.5);
};
} else if (k === 'vol') {
h('Volume');
const w = document.createElement('div');
w.className = 'vrow';
w.innerHTML = '<div class="pic f" id="vm"></div><div class="vs f" id="vsl"><i></i></div><span id="vpc"></span>';
pm.appendChild(w);
const vm = $('#vm');
const vs = $('#vsl');
const vu = () => {
const v = vd.muted ? 0 : vd.volume;
vs.firstChild.style.width = v * 100 + '%';
$('#vpc').textContent = Math.round(v * 100);
vm.innerHTML = $('#pvol').innerHTML = v ? PI.vol : PI.mute;
};
vm._a = () => {
vd.muted = !vd.muted;
vu();
};
vs._a = () => { };
vs._v = vu;
vs.addEventListener('click', e => {
const b = vs.getBoundingClientRect();
vd.muted = false;
vd.volume = Math.min(1, Math.max(0, (e.clientX - b.left) / b.width));
vu();
});
vu();
first = vs;
} else if (k === 'set') {
h('Available Sources');
const srcContainer = document.createElement('div');
srcContainer.id = 'pm-src-list';
pm.appendChild(srcContainer);
renderSourcesMenu();
h('Playback Speed');
[.5, 1, 1.25, 1.5, 2].forEach(v => r(v === 1 ? 'Normal' : v + '\u00d7', vd.playbackRate === v, () => vd.playbackRate = v));
h('Aspect Ratio');
r('Fit Screen', !pl.classList.contains('cover'), () => {
pl.classList.remove('cover');
if (pbg) {
pbg.currentTime = vd.currentTime;
if (!vd.paused) pbg.play().catch(() => { });
}
});
r('Fill Screen', pl.classList.contains('cover'), () => {
pl.classList.add('cover');
if (pbg) pbg.pause();
});
} else {
h('Library');
r('Watch Later', has('wl', plItem), () => toggleLib('wl', plItem));
r('Favorites', has('fav', plItem), () => toggleLib('fav', plItem));
}
showCtl();
setFocus(first || pm.querySelector('.f'));
}
async function openPlayer(it, ep, ctx) {
if (!it) return;
plPrev = cur;
plItem = it;
plCtx = ctx || null;
userHasManuallySelectedSource = false;
$('#peps').style.display = plCtx ? '' : 'none';
pl.classList.remove('eps');
$('#pnext').classList.remove('v');
$('#pt').textContent = it.title || it.name || '';
$('#ps').textContent = ep || [yr(it), it._t === 'tv' ? 'Series' : 'Movie'].filter(Boolean).join(' \u2022 ');
$('#pav').src = it.poster_path ? I + 'w185' + it.poster_path : PH;
vd.poster = it.backdrop_path ? I + 'w1280' + it.backdrop_path : '';
pl.classList.add('show');
showCtl();
pico();
setFocus($('#pmid'));
if (plCtx) {
const epData = plCtx.eps[plCtx.i];
startConcurrentDiscovery(it, plCtx.s, epData.episode_number);
} else {
startConcurrentDiscovery(it);
}
}
function closePlayer() {
closeMenu();
closeEps();
if (currentHls) {
currentHls.destroy();
currentHls = null;
}
if (currentBgHls) {
currentBgHls.destroy();
currentBgHls = null;
}
plCtx = null;
vd.pause();
vd.removeAttribute('src');
vd.load();
if (pbg) {
pbg.pause();
pbg.removeAttribute('src');
pbg.load();
}
if (document.fullscreenElement) document.exitFullscreen();
pl.classList.remove('show', 'ui');
if (plPrev && document.body.contains(plPrev)) setFocus(plPrev);
}
$('#pback')._a = closePlayer;
$('#pmid')._a = tgl;
$('#pskip')._a = () => {
if (activeSegment) {
const target = activeSegment.end != null ? activeSegment.end + 0.5 : (vd.duration || vd.currentTime + 30);
vd.currentTime = target;
$('#pskip').classList.remove('v');
activeSegment = null;
} else {
vd.currentTime += 85;
}
};
$('#pqlt')._a = () => openMenu('qlt');
$('#pcc')._a = () => openMenu('cc');
$('#pvol')._a = () => openMenu('vol');
$('#pset')._a = () => openMenu('set');
$('#pmore')._a = () => openMenu('more');
$('#pfs')._a = () => document.fullscreenElement ? document.exitFullscreen() : pl.requestFullscreen && pl.requestFullscreen();
document.addEventListener('fullscreenchange', () => {
$('#pfs').innerHTML = document.fullscreenElement ? PI.fx : PI.fs;
});
const sp = e => {
const b = sk.getBoundingClientRect();
vd.currentTime = Math.min(1, Math.max(0, (e.clientX - b.left) / b.width)) * (vd.duration || 0);
};
sk.addEventListener('pointerdown', e => {
drag = 1;
sk._m = 1;
sk.setPointerCapture(e.pointerId);
sp(e);
});
sk.addEventListener('pointermove', e => {
if (drag) sp(e);
});
sk.addEventListener('pointerup', () => drag = 0);
sk._a = () => {
if (sk._m) {
sk._m = 0;
return;
}
tgl();
};
pl.querySelectorAll('.f').forEach(e => {
if (!e._a) e._a = () => { };
});
vd.addEventListener('click', () => {
if (pmK) closeMenu();
else if (pl.classList.contains('eps')) closeEps();
else tgl();
showCtl();
});
vd.addEventListener('play', () => {
if (!pl.classList.contains('cover') && pbg) {
pbg.muted = true;
pbg.currentTime = vd.currentTime;
pbg.play().catch(() => { });
}
});
vd.addEventListener('pause', () => {
if (pbg) pbg.pause();
});
vd.addEventListener('seeking', () => {
if (pbg) pbg.currentTime = vd.currentTime;
});
vd.addEventListener('seeked', () => {
if (pbg) pbg.currentTime = vd.currentTime;
});
vd.addEventListener('ratechange', () => {
if (pbg) pbg.playbackRate = vd.playbackRate;
});
vd.addEventListener('durationchange', () => {
$('#pdur').textContent = fmt(vd.duration);
if (vd.duration && plItem) {
const c = plCtx;
loadTimestamps(plItem.id, c ? c.s : null, c ? c.eps[c.i].episode_number : null, vd.duration);
}
});
vd.addEventListener('timeupdate', () => {
const d = vd.duration || 0;
const t = vd.currentTime;
const p = d ? (t / d) * 100 : 0;
$('#pfill').style.width = p + '%';
$('#phd').style.left = p + '%';
$('#pcur').textContent = fmt(t);
if (d && vd.buffered.length) {
$('#pbuf').style.width = (vd.buffered.end(vd.buffered.length - 1) / d * 100) + '%';
}
if (!pl.classList.contains('cover') && pbg && Math.abs(pbg.currentTime - t) > 0.35) {
pbg.currentTime = t;
}
const currentSeg = mediaSegments.find(seg => {
const afterStart = t >= seg.start;
const beforeEnd = seg.end == null ? (d ? t < d - 5 : true) : t < seg.end;
return afterStart && beforeEnd;
});
if (currentSeg) {
activeSegment = currentSeg;
$('#pskip').textContent = currentSeg.label;
$('#pskip').classList.add('v');
} else {
activeSegment = null;
$('#pskip').classList.remove('v');
}
});
['play', 'pause', 'ended'].forEach(n => vd.addEventListener(n, pico));
['loadstart', 'waiting', 'seeking'].forEach(n => vd.addEventListener(n, () => pl.classList.add('buf')));
['playing', 'canplay', 'seeked', 'error'].forEach(n => vd.addEventListener(n, () => pl.classList.remove('buf')));
const hasNext = () => !!plCtx && (plCtx.i + 1 < plCtx.eps.length || plCtx.seasons.some(x => x.season_number > plCtx.s));
const getSeason = async s => plCtx.cache[s] || (plCtx.cache[s] = (await api(`/tv/${plCtx.it.id}/season/${s}`)).episodes || []);
async function playEp(s, i) {
const c = plCtx;
if (!c) return;
const eps = await getSeason(s);
const p = eps[i];
if (!p || plCtx !== c) return;
c.s = s;
c.i = i;
c.eps = eps;
$('#ps').textContent = `S${s} E${p.episode_number} \u2022 ${p.name || ''}`;
$('#pnext').classList.remove('v');
closeEps();
showCtl();
setFocus($('#pmid'));
userHasManuallySelectedSource = false;
startConcurrentDiscovery(c.it, s, p.episode_number);
}
function nextEp() {
const c = plCtx;
if (!hasNext()) return;
if (c.i + 1 < c.eps.length) {
playEp(c.s, c.i + 1);
} else {
playEp(c.seasons.find(x => x.season_number > c.s).season_number, 0);
}
}
function closeEps(rf) {
if (!pl.classList.contains('eps')) return;
pl.classList.remove('eps');
if (rf) setFocus($('#peps'));
}
function openEps() {
const c = plCtx;
if (!c) return;
closeMenu();
$('#peT').innerHTML = `Episodes<small>${esc(c.it.title || c.it.name || '')}</small>`;
const sr = $('#pseas');
sr.innerHTML = '';
c.seasons.forEach(x => {
sr.appendChild(mkf('chip', esc(x.name || 'Season ' + x.season_number), () => showSeason(x.season_number)));
});
pl.classList.add('eps');
showCtl();
showSeason(c.s, true);
}
async function showSeason(sn, focus) {
const c = plCtx;
const car = $('#pcar');
if (!c) return;
viewS = sn;
[...$('#pseas').children].forEach((e, i) => e.classList.toggle('act', c.seasons[i].season_number === sn));
car.innerHTML = SK.pep(8);
const eps = await getSeason(sn);
if (viewS !== sn || plCtx !== c) return;
car.innerHTML = '';
eps.forEach((p, i) => {
const n = p.episode_number;
const cur = c.s === sn && c.i === i;
const img = p.still_path ? I + 'w300' + p.still_path : (c.it.backdrop_path ? I + 'w300' + c.it.backdrop_path : PH);
car.appendChild(mkf('pe' + (cur ? ' cur' : ''), `<div class="et"><img loading="lazy" src="${img}" alt=""><i>${n}</i>${cur ? '<span class="eq"><u></u><u></u><u></u></span><span class="pg"><u></u></span>' : ''}</div><span class="pn">${esc(p.name || 'Episode ' + n)}</span><small>${p.runtime ? p.runtime + 'm' : 'Episode ' + n}</small>`, () => playEp(sn, i)));
});
const t = car.querySelector('.cur') || car.firstChild;
if (focus && t) setFocus(t);
}
$('#pep').innerHTML = `<div class="peh"><h3 id="peT"></h3><div class="row" id="pseas"></div><div class="pic f" id="pepx">${PI.x}</div></div><div class="row pcar" id="pcar"></div>`;
$('#pnext').innerHTML = 'Next Episode' + pi('<path d="M6 5.5v13l9-6.5z"/><path d="M18 5.5v13"/>');
$('#pnext')._a = nextEp;
$('#pepx')._a = () => closeEps(1);
$('#peps')._a = () => pl.classList.contains('eps') ? closeEps(1) : openEps();
vd.addEventListener('timeupdate', () => {
const d = vd.duration || 0;
const r = d - vd.currentTime;
$('#pnext').classList.toggle('v', !!d && hasNext() && r <= 300 && r > 0);
$('#pep').style.setProperty('--p', d ? (vd.currentTime / d) * 100 + '%' : '0%');
});
vd.addEventListener('ended', () => {
if (hasNext()) nextEp();
});

View File

@@ -1,70 +0,0 @@
let sqv = '', bgT, stab = 0, SD = { q: '', all: [], mv: [], tv: [] };
const SVS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>';
const sdraw = () => { const t = $('#stext'); t.textContent = sqv || 'Search'; t.classList.toggle('q', !!sqv) };
function setQ(v) { sqv = v; sdraw(); clearTimeout(st); st = setTimeout(() => fillSearch(sqv.trim()), 300) }
function sbgf(it) { clearTimeout(bgT); bgT = setTimeout(() => xf(bgb, I + 'w780' + (it.backdrop_path || it.poster_path)), 120) }
async function fillSearch(q) {
const my = ++sgen; skelSearch();
let all, mv, tv;
const ok = x => x.poster_path && (x.media_type === 'movie' || x.media_type === 'tv');
if (q) {
const d = await api('/search/multi', `&query=${encodeURIComponent(q)}&include_adult=false`);
if (my !== sgen) return;
all = (d.results || []).filter(ok); mv = all.filter(x => x.media_type === 'movie'); tv = all.filter(x => x.media_type === 'tv')
}
else {
const [a, b] = await Promise.all([api('/trending/movie/week'), api('/trending/tv/week')]);
if (my !== sgen) return;
mv = (a.results || []).filter(x => x.poster_path).map(x => ({ ...x, media_type: 'movie' }));
tv = (b.results || []).filter(x => x.poster_path).map(x => ({ ...x, media_type: 'tv' })); all = []
}
SD = { q, all, mv, tv }; drawSearch()
}
function tile(x) {
x._t = x.media_type;
const e = mkf('sc', `<div class="po"><img src="${I}w342${x.poster_path}" alt=""></div><b>${esc(x.title || x.name || '')}</b><small><span>&#9733; ${(x.vote_average || 0).toFixed(1)}</span><span>${yr(x)}</span><span>${x.media_type === 'tv' ? 'Show' : 'Movie'}</span></small>`, () => openDetail(x));
e._bg = x; return e
}
function drawSearch() {
const { q, all, mv, tv } = SD;
chips.innerHTML = '';
const names = [...new Set((q ? all : [...mv, ...tv]).map(x => x.title || x.name))].slice(0, 9);
if (q) chips.appendChild(mkf('chip', SVS + `"${esc(q)}"`, () => { }));
names.forEach(n => chips.appendChild(mkf('chip', esc(n), () => setQ(n))));
sres.innerHTML = '';
const L = stab === 1 ? [['Movies', mv]] : stab === 2 ? [['TV Shows', tv]] : q ? [['Top Matches', all.slice(0, 10)], ['Movies', mv], ['TV Shows', tv]] : [['Trending Movies', mv], ['Trending TV Shows', tv]];
let n = 0;
L.forEach(([h, a]) => { if (!a.length) return; n++; const sec = document.createElement('section'); sec.className = 'ss'; sec.innerHTML = `<h3>${h}</h3><div class="row ch"></div>`; sec.lastChild.append(...a.map(tile)); sres.appendChild(sec) });
if (!n) sres.innerHTML = `<p class="none">No results${q ? ` for \u201c${esc(q)}\u201d` : ''}</p>`;
[...tabs.children].forEach((t, i) => t.classList.toggle('act', i === stab));
if (cur && !document.body.contains(cur)) setFocus(chips.children[1] || chips.firstChild || kb.children[2])
}
['Top Results', 'Movies', 'TV Shows'].forEach((t, i) => tabs.appendChild(mkf('tab', t, () => { stab = i; drawSearch() })));
const KL = 'abcdefghijklmnopqrstuvwxyz'.split(''), KN = "1234567890.-'&:".split('');
let num = false;
function buildKb() {
kb.innerHTML = '';
const mk = (label, cls, fn) => kb.appendChild(mkf('k ' + cls, label, () => { fn(); sr.scrollTo({ top: 0, behavior: 'smooth' }) }));
mk(num ? 'ABC' : '123', 'w', () => { num = !num; buildKb(); setFocus(kb.firstChild) });
mk('SPACE', 'w', () => { if (sqv && !sqv.endsWith(' ')) setQ(sqv + ' ') });
(num ? KN : KL).forEach(c => mk(c, '', () => setQ(sqv + c)));
mk('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5h11a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H9l-6-7z"/><path d="m12 9 5 5m0-5-5 5"/></svg>', 'd', () => setQ(sqv.slice(0, -1)))
}
buildKb();
function openSearch() {
setAct('search'); document.body.classList.add('srch'); sr.classList.add('show'); side.classList.remove('open'); sr.scrollTop = 0;
if (!sres.children.length) fillSearch('');
setFocus(kb.children[2])
}
function closeSearch(silent) {
if (!sr.classList.contains('show')) return;
sr.classList.remove('show'); document.body.classList.remove('srch');
heroIt = null; bgb._u = null;
if (!silent) { setAct(mode); setFocus(document.querySelector('#rows .card')) }
}
function skelSearch() {
chips.innerHTML = SK.chips(7);
sres.innerHTML = [0, 1].map(() => `<section class="ss"><i class="sb h3s"></i><div class="row ch">${SK.tiles(9)}</div></section>`).join('')
}

View File

@@ -1,16 +0,0 @@
const NAV = [
['search', 'Search', ic('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>')],
['discover', 'Discovery', ic('<circle cx="12" cy="12" r="9"/><path d="m15.5 8.5-2 5-5 2 2-5z"/>')],
['movie', 'Movies', ic('<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 3v18M17 3v18M3 8h4M3 12h4M3 16h4M17 8h4M17 12h4M17 16h4"/>')],
['tv', 'TV Shows', ic('<rect x="3" y="7" width="18" height="13" rx="2"/><path d="m8 3 4 4 4-4"/>')],
['wl', 'Watch Later', ic(P_WL)],
['fav', 'Favorites', ic(P_FV)]
];
NAV.forEach(([k, n, s]) => { const e = document.createElement('div'); e.className = 'ni f'; e.tabIndex = -1; e.innerHTML = s + `<span>${n}</span>`; e._a = () => go(k); NAVEL[k] = e; $('#nav').appendChild(e) });
syncNav();
function setAct(k) { Object.entries(NAVEL).forEach(([n, e]) => e.classList.toggle('act', n === k)); navAct = NAVEL[k] }
const mark = $('#mark'); mark.onerror = () => { mark.onerror = null; mark.src = PH }; mark.src = LOGO;
function syncNav() { ['wl', 'fav'].forEach(k => { if (NAVEL[k]) NAVEL[k].style.display = (LIB[k].length || libK === k) ? '' : 'none' }) }

View File

@@ -1,13 +0,0 @@
const SK = {
cards: n => '<div class="card sl"></div>'.repeat(n),
tiles: n => '<div class="sc sl"><div class="po"></div><i class="sb"></i><i class="sb s"></i></div>'.repeat(n),
chips: n => Array.from({ length: n }, (_, i) => `<div class="chip sl" style="width:${90 + (i * 37) % 80}px"></div>`).join(''),
pep: n => '<div class="pe sl"><div class="et"></div><i class="sb" style="width:70%;height:14px;margin-top:11px"></i><i class="sb" style="width:35%;height:11px;margin-top:6px"></i></div>'.repeat(n),
eps: n => '<div class="ep sl"><div class="et"></div><i class="sb" style="width:70%;height:14px;margin-top:12px"></i><i class="sb" style="width:40%;height:11px;margin-top:6px"></i></div>'.repeat(n),
rows: n => Array.from({ length: n }, () => '<section class="sec"><i class="sb h2"></i><div class="row">' + SK.cards(14) + '</div></section>').join(''),
detail: it => {
const bp = it.backdrop_path || it.poster_path || '';
return `<div class="dbl" style="background-image:url(${I}w780${bp})"></div><div class="dw"><div class="dimg" style="background-image:url(${I}w1280${bp})"></div><div class="dc"><div class="dh dsk"><i class="sb t"></i><i class="sb m"></i><div class="hb"><i class="sb b"></i><i class="sb c"></i></div><i class="sb l"></i><i class="sb l"></i><i class="sb l x"></i></div>` +
`<div class="ds"><i class="sb h3"></i><div class="row">${'<div class="trk"></div>'.repeat(4)}</div></div><div class="ds"><i class="sb h3"></i><div class="row">${SK.cards(9)}</div></div></div></div>`
}
};