15 Commits

Author SHA1 Message Date
09ddbe6bae 1.0.6 2026-10-05 20:40:56 -07:00
494d8e6835 fixed watch history 2026-10-05 20:38:56 -07:00
1c3ffacccd 1.0.5 2026-10-05 15:50:17 -07:00
44a6e3cef9 1.0.5 2026-10-05 15:47:25 -07:00
9c0c5bf17d tweaked ui 2026-10-05 15:47:10 -07:00
01e532a6a5 v1.0.4 2026-10-05 13:22:52 -07:00
58c7889b8a 1.0.4 2026-10-05 13:21:35 -07:00
102c3bdc84 great UI update 2026-10-05 13:21:01 -07:00
112d405e75 v1.0.3 2026-10-05 11:11:30 -07:00
c3358fee44 fixed outdated capacitor 2026-10-05 11:08:26 -07:00
818b03cfd3 1.0.3 2026-10-05 11:04:39 -07:00
09b03508ed updated md 2026-10-05 11:03:59 -07:00
30d21ef39b fixed Ui controls on player 2026-10-05 11:01:14 -07:00
a0621f725a rewrote the entire app using jetpack compose, and exoplayer/media3 for playback 2026-10-05 11:00:55 -07:00
ecbf5953d6 v1.0.2 2026-10-04 22:04:57 -07:00
85 changed files with 6666 additions and 9661 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,9 +1,19 @@
# Vyla TV
Vyla TV is an Android TV application built with HTML, CSS, JavaScript, and Capacitor.
An Android TV streaming app built with Jetpack Compose and ExoPlayer.
## Prerequisites
## Features
- **Node.js**: v18+ or v20+
- **JDK**: Java Development Kit 17
- **Android SDK**: Installed via Android Studio with `ANDROID_HOME` configured in your environment variables.
- Browse movies and TV shows from TMDB
- Stream content with multiple source providers
- D-pad navigation optimized for TV
- Playback speed control, quality selection, and subtitles
- Watch Later and Favorites libraries
## Build
```bash
npm run dev
```
This builds the APK as `vyla.apk`.

5
android/.gitignore vendored
View File

@@ -89,13 +89,8 @@ lint/tmp/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

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

@@ -1,19 +0,0 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

View File

@@ -1,26 +0,0 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
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,187 @@
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
import androidx.compose.ui.draw.drawWithContent
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 historyItems by libraryManager.history.collectAsState()
val sidebarFocusRequester = remember { FocusRequester() }
val contentFocusRequester = remember { FocusRequester() }
BackHandler(enabled = playingItem == null && activeDetailItem == null) {
if (currentDestination != NavDestination.DISCOVERY && currentDestination != NavDestination.ANIME) {
currentDestination = NavDestination.DISCOVERY
} else {
finish()
}
}
Box(modifier = Modifier.fillMaxSize().background(Color(0xFF07070A))) {
if (playingItem == null) {
Sidebar(
currentDestination = currentDestination,
hasHistory = historyItems.isNotEmpty(),
hasWatchLater = watchLaterItems.isNotEmpty(),
hasFavorites = favoriteItems.isNotEmpty(),
onNavigate = { dest ->
currentDestination = dest
activeDetailItem = null
contentFocusRequester.requestFocus()
},
onRequestExitSidebar = {
try {
contentFocusRequester.requestFocus()
} catch (_: Exception) {}
},
modifier = Modifier
.focusRequester(sidebarFocusRequester)
.zIndex(5f)
)
}
val contentCovered = playingItem != null || activeDetailItem != null
Box(
modifier = Modifier
.fillMaxSize()
.focusRequester(contentFocusRequester)
.drawWithContent { if (!contentCovered) drawContent() }
) {
when (currentDestination) {
NavDestination.DISCOVERY,
NavDestination.MOVIES,
NavDestination.TV_SHOWS,
NavDestination.ANIME -> {
BrowseScreen(
mode = currentDestination,
onOpenDetail = { item -> activeDetailItem = item },
onPlay = { item ->
libraryManager.addHistory(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.HISTORY -> {
LibraryScreen(
title = "Watch History",
items = historyItems,
onOpenDetail = { item -> activeDetailItem = item },
emptyMessage = "Nothing watched yet. Press play on a title and it shows up here."
)
}
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 ->
libraryManager.addHistory(itm)
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,84 @@
package tv.vyla.app.api
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
data class SkipSegment(
val type: String,
val start: Double,
val end: Double?,
val label: String
)
object IntroDbV3 {
private const val BASE_URL = "https://api.theintrodb.org/v3/media"
suspend fun getSegments(
tmdbId: Number,
season: Int?,
episode: Int?,
durationMs: Long? = null
): List<SkipSegment> = withContext(Dispatchers.IO) {
try {
val url = StringBuilder("$BASE_URL?tmdb_id=$tmdbId")
if (season != null && episode != null) {
url.append("&season=$season&episode=$episode")
}
if (durationMs != null && durationMs > 0L) {
url.append("&duration_ms=$durationMs")
}
val conn = URL(url.toString()).openConnection() as HttpURLConnection
conn.connectTimeout = 8000
conn.readTimeout = 8000
conn.setRequestProperty("Accept", "application/json")
try {
if (conn.responseCode != 200) {
emptyList()
} else {
val body = conn.inputStream.bufferedReader().use { it.readText() }
parse(JSONObject(body))
}
} finally {
conn.disconnect()
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
emptyList()
}
}
private fun parse(json: JSONObject): List<SkipSegment> {
val result = ArrayList<SkipSegment>()
val kinds = listOf(
Triple("intro", "Skip Intro", true),
Triple("recap", "Skip Recap", true),
Triple("credits", "Skip Credits", false),
Triple("preview", "Skip Preview", false)
)
for ((key, label, startOptional) in kinds) {
val array = json.optJSONArray(key) ?: continue
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val startMs = if (obj.isNull("start_ms")) null else obj.optLong("start_ms")
val endMs = if (obj.isNull("end_ms")) null else obj.optLong("end_ms")
if (startOptional && endMs == null) continue
val resolvedStart = startMs ?: (if (startOptional) 0L else null) ?: continue
if (endMs != null && endMs <= resolvedStart) continue
result.add(
SkipSegment(
type = key,
start = resolvedStart / 1000.0,
end = endMs?.let { it / 1000.0 },
label = label
)
)
}
}
return result.sortedBy { it.start }
}
}

View File

@@ -0,0 +1,335 @@
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 getAnimeDiscover(genreId: Int, page: Int): List<MediaItem> = withContext(Dispatchers.IO) {
val path = "/discover/tv?with_genres=$genreId&with_original_language=ja&sort_by=popularity.desc&vote_count.gte=50&include_adult=false&page=$page"
val json = fetchJson(path) ?: return@withContext emptyList()
parseMediaList(json.optJSONArray("results"), "tv")
}
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 const val CACHE_TTL_MS = 10 * 60 * 1000L
private val responseCache = android.util.LruCache<String, Pair<Long, JSONObject>>(80)
private suspend fun fetchJson(path: String): JSONObject? = withContext(Dispatchers.IO) {
val now = System.currentTimeMillis()
responseCache.get(path)?.let { (at, cached) ->
if (now - at < CACHE_TTL_MS) return@withContext cached
}
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).also { responseCache.put(path, now to it) }
} else null
} else null
}
} catch (_: Exception) {
null
}
}
}

View File

@@ -0,0 +1,142 @@
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()
private val _history = MutableStateFlow<List<MediaItem>>(emptyList())
val history: StateFlow<List<MediaItem>> = _history.asStateFlow()
init {
load()
}
fun addHistory(item: MediaItem) {
val current = _history.value.toMutableList()
current.removeAll { it.id == item.id && it.mediaType == item.mediaType }
current.add(0, item)
_history.value = current.take(100)
save()
}
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"))
_history.value = parseList(root.optJSONArray("hist"))
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun save() {
try {
val root = JSONObject()
root.put("wl", serializeList(_watchLater.value))
root.put("fav", serializeList(_favorites.value))
root.put("hist", serializeList(_history.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,305 @@
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
import java.util.Collections
import java.util.concurrent.Executors
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"
}
private class PoolRunner : AsyncRunner {
private val pool = Executors.newCachedThreadPool()
private val running = Collections.synchronizedList(ArrayList<ClientHandler>())
override fun closeAll() {
synchronized(running) { ArrayList(running).forEach { it.close() } }
}
override fun closed(clientHandler: ClientHandler) {
running.remove(clientHandler)
}
override fun exec(clientHandler: ClientHandler) {
running.add(clientHandler)
pool.execute(clientHandler)
}
}
init {
setAsyncRunner(PoolRunner())
}
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,70 @@
package tv.vyla.app.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
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.FilterQuality
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil.compose.AsyncImage
import coil.request.ImageRequest
val HeroFadeStops: Array<Pair<Float, Float>> = arrayOf(
0f to 1f,
0.38f to 1f,
0.50f to 0.92f,
0.62f to 0.70f,
0.74f to 0.42f,
0.86f to 0.16f,
1f to 0f
)
@Composable
fun AmbientBackdrop(url: String?, modifier: Modifier = Modifier, durationMs: Int = 600) {
val context = LocalContext.current
Crossfade(
targetState = url,
modifier = modifier,
animationSpec = tween(durationMs),
label = "ambient_backdrop"
) { target ->
if (!target.isNullOrEmpty()) {
val request = remember(target) {
ImageRequest.Builder(context)
.data(target)
.size(48, 27)
.allowHardware(false)
.build()
}
AsyncImage(
model = request,
contentDescription = null,
contentScale = ContentScale.Crop,
filterQuality = FilterQuality.Medium,
modifier = Modifier.fillMaxSize()
)
}
}
}
fun Modifier.verticalFadeMask(vararg stops: Pair<Float, Float>): Modifier =
this
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithCache {
val brush = Brush.verticalGradient(
*Array(stops.size) { stops[it].first to Color.Black.copy(alpha = stops[it].second) }
)
onDrawWithContent {
drawContent()
drawRect(brush = brush, blendMode = BlendMode.DstIn)
}
}

View File

@@ -0,0 +1,103 @@
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
import androidx.compose.ui.graphics.graphicsLayer
@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)
.graphicsLayer {
scaleX = scale
scaleY = 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,48 @@
package tv.vyla.app.ui.components
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.unit.dp
object SearchIcons {
val Space: ImageVector by lazy {
build("Space", "M4 10v6h16v-6")
}
val Search: ImageVector by lazy {
build("Search", "M11 4a7 7 0 1 0 0 14a7 7 0 0 0 0-14z", "M16 16l5 5")
}
val Backspace: ImageVector by lazy {
build(
"Backspace",
"M21 5H8l-6 7 6 7h13a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1z",
"M12 9.5l5 5",
"M17 9.5l-5 5"
)
}
private fun build(name: String, vararg paths: String): ImageVector {
val builder = ImageVector.Builder(
name = name,
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
)
paths.forEach { d ->
builder.addPath(
pathData = addPathNodes(d),
stroke = SolidColor(Color.Black),
strokeLineWidth = 2f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
)
}
return builder.build()
}
}

View File

@@ -0,0 +1,262 @@
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
import kotlinx.coroutines.delay
import androidx.compose.foundation.focusGroup
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.focus.focusProperties
object ContentFocusBridge {
var restore: (() -> Boolean)? = null
}
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),
ANIME("anime", "Anime", Icons.Default.AutoAwesome),
HISTORY("history", "History", Icons.Default.History),
WATCH_LATER("wl", "Watch Later", Icons.Default.Schedule),
FAVORITES("fav", "Favorites", Icons.Default.Favorite)
}
@Composable
@OptIn(ExperimentalComposeUiApi::class)
fun Sidebar(
currentDestination: NavDestination,
hasHistory: Boolean,
hasWatchLater: Boolean,
hasFavorites: Boolean,
onNavigate: (NavDestination) -> Unit,
onRequestExitSidebar: () -> Unit,
modifier: Modifier = Modifier
) {
var focusedDestination by remember { mutableStateOf<NavDestination?>(null) }
val isSidebarFocused = focusedDestination != null
val destinationFocusRequesters = remember { mutableMapOf<NavDestination, FocusRequester>() }
val animatedWidth by animateDpAsState(
targetValue = if (isSidebarFocused) 220.dp else 58.dp,
animationSpec = tween(durationMillis = 220),
label = "sidebar_width"
)
LaunchedEffect(Unit) {
delay(60)
try {
destinationFocusRequesters[currentDestination]?.requestFocus()
} catch (_: Exception) {}
}
Box(
modifier = modifier
.fillMaxHeight()
.width(animatedWidth)
.zIndex(10f)
.focusProperties {
enter = { destinationFocusRequesters[currentDestination] ?: FocusRequester.Default }
}
.focusGroup()
.background(
Brush.horizontalGradient(
colors = if (isSidebarFocused) {
listOf(Color(0xF207070A), Color(0xCC07070A), Color.Transparent)
} else {
listOf(Color(0xD907070A), Color.Transparent)
}
)
)
.padding(vertical = 20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.align(Alignment.TopStart)
.padding(start = 14.dp, top = 2.dp)
.height(36.dp)
) {
Box(
modifier = Modifier
.size(30.dp)
.clip(CircleShape)
.background(Color.White),
contentAlignment = Alignment.Center
) {
androidx.compose.foundation.Image(
painter = painterResource(R.drawable.icon),
contentDescription = "Vyla Logo",
modifier = Modifier.size(24.dp),
contentScale = ContentScale.Fit
)
}
AnimatedVisibility(
visible = isSidebarFocused,
enter = fadeIn(tween(160)),
exit = fadeOut(tween(100))
) {
Text(
text = "Vyla",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.5).sp,
modifier = Modifier.padding(start = 12.dp)
)
}
}
Column(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
NavDestination.values().forEach { destination ->
val shouldShow = when (destination) {
NavDestination.HISTORY -> hasHistory || currentDestination == NavDestination.HISTORY
NavDestination.WATCH_LATER -> hasWatchLater || currentDestination == NavDestination.WATCH_LATER
NavDestination.FAVORITES -> hasFavorites || currentDestination == NavDestination.FAVORITES
else -> true
}
if (shouldShow) {
val requester = destinationFocusRequesters.getOrPut(destination) { FocusRequester() }
SidebarItem(
destination = destination,
isSelected = currentDestination == destination,
isExpanded = isSidebarFocused,
focusRequester = requester,
onFocusGained = { focusedDestination = destination },
onFocusLost = { if (focusedDestination == destination) focusedDestination = null },
onSelect = {
focusedDestination = null
onNavigate(destination)
},
onRightPress = {
focusedDestination = null
if (ContentFocusBridge.restore?.invoke() != true) onRequestExitSidebar()
}
)
}
}
}
}
}
@Composable
private fun SidebarItem(
destination: NavDestination,
isSelected: Boolean,
isExpanded: Boolean,
focusRequester: FocusRequester? = null,
onFocusGained: () -> Unit,
onFocusLost: () -> Unit,
onSelect: () -> Unit,
onRightPress: () -> Unit
) {
var isItemFocused by remember { mutableStateOf(false) }
val background = when {
isItemFocused -> Color.White
else -> Color.Transparent
}
val contentColor = when {
isItemFocused -> Color.Black
isSelected -> Color.White
else -> Color(0x99FFFFFF)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(42.dp)
.clip(RoundedCornerShape(21.dp))
.background(background)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged { state ->
isItemFocused = state.isFocused
if (state.isFocused) onFocusGained() else onFocusLost()
}
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionRight) {
onRightPress()
true
} else {
false
}
}
.focusable()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
onSelect()
}
) {
Box(
modifier = Modifier.size(42.dp),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = destination.icon,
contentDescription = destination.title,
tint = contentColor,
modifier = Modifier.size(22.dp)
)
}
AnimatedVisibility(
visible = isExpanded,
enter = fadeIn(tween(160)),
exit = fadeOut(tween(100))
) {
Text(
text = destination.title,
color = contentColor,
fontSize = 15.sp,
fontWeight = if (isSelected || isItemFocused) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
modifier = Modifier.padding(start = 2.dp, end = 14.dp)
)
}
}
}

View File

@@ -0,0 +1,187 @@
package tv.vyla.app.ui.components
import android.graphics.Bitmap
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.PixelCopy
import android.view.SurfaceView
import android.view.TextureView
import android.view.View
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalView
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.math.roundToInt
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.BitmapDrawable
import androidx.compose.ui.platform.LocalContext
import coil.imageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
private const val GRID_W = 32
private const val GRID_H = 18
class VideoFrameColors(
private val pixels: IntArray,
private val width: Int,
private val height: Int
) {
fun colorAt(fx: Float, fy: Float): Color {
val cx = (fx.coerceIn(0f, 1f) * (width - 1)).roundToInt()
val cy = (fy.coerceIn(0f, 1f) * (height - 1)).roundToInt()
var r = 0
var g = 0
var b = 0
var n = 0
for (dy in -1..1) {
for (dx in -1..1) {
val x = (cx + dx).coerceIn(0, width - 1)
val y = (cy + dy).coerceIn(0, height - 1)
val p = pixels[y * width + x]
r += (p shr 16) and 0xFF
g += (p shr 8) and 0xFF
b += p and 0xFF
n++
}
}
return Color(r / n, g / n, b / n)
}
}
object VideoTintState {
var frame: VideoFrameColors? by mutableStateOf(null)
}
@Composable
fun VideoTintSampler(active: Boolean, viewProvider: () -> View?) {
val provider by rememberUpdatedState(viewProvider)
DisposableEffect(Unit) {
onDispose { VideoTintState.frame = null }
}
LaunchedEffect(active) {
if (!active) return@LaunchedEffect
val bitmap = Bitmap.createBitmap(GRID_W, GRID_H, Bitmap.Config.ARGB_8888)
val pixels = IntArray(GRID_W * GRID_H)
while (true) {
val view = provider()
if (view != null && captureFrame(view, bitmap)) {
bitmap.getPixels(pixels, 0, GRID_W, 0, 0, GRID_W, GRID_H)
VideoTintState.frame = VideoFrameColors(pixels.copyOf(), GRID_W, GRID_H)
}
delay(700)
}
}
}
private suspend fun captureFrame(view: View, bitmap: Bitmap): Boolean {
return when (view) {
is SurfaceView -> {
if (Build.VERSION.SDK_INT < 24 || !view.holder.surface.isValid || view.width == 0 || view.height == 0) {
false
} else {
suspendCancellableCoroutine { cont ->
try {
PixelCopy.request(
view,
bitmap,
{ result -> if (cont.isActive) cont.resume(result == PixelCopy.SUCCESS) },
Handler(Looper.getMainLooper())
)
} catch (_: Exception) {
if (cont.isActive) cont.resume(false)
}
}
}
}
is TextureView -> view.isAvailable && view.getBitmap(bitmap) != null
else -> false
}
}
private fun tinted(sample: Color, selected: Boolean): Color {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(sample.toArgb(), hsv)
hsv[1] = (hsv[1] * 1.6f).coerceAtMost(1f)
hsv[2] = if (selected) {
(hsv[2] * 0.9f).coerceIn(0.3f, 0.85f)
} else {
(hsv[2] * 0.5f).coerceIn(0.05f, 0.45f)
}
return Color(android.graphics.Color.HSVToColor(hsv)).copy(alpha = if (selected) 0.9f else 0.8f)
}
@Composable
fun Modifier.videoTint(isFocused: Boolean, isSelected: Boolean = false): Modifier {
val frame = VideoTintState.frame
val rootView = LocalView.current.rootView
var center by remember { mutableStateOf(Offset.Unspecified) }
val target = when {
isFocused -> Color(0xF2FFFFFF)
frame == null || center == Offset.Unspecified || rootView.width == 0 || rootView.height == 0 ->
if (isSelected) Color(0xCC1A1A1A) else Color(0x99020202)
else -> tinted(
frame.colorAt(center.x / rootView.width, center.y / rootView.height),
isSelected
)
}
val animated by animateColorAsState(target, tween(350), label = "video_tint")
return this
.onGloballyPositioned { center = it.boundsInWindow().center }
.drawBehind { drawRect(animated) }
}
@Composable
fun BackdropTintSampler(url: String?) {
val context = LocalContext.current
DisposableEffect(Unit) {
onDispose { VideoTintState.frame = null }
}
LaunchedEffect(url) {
if (url.isNullOrEmpty()) {
VideoTintState.frame = null
return@LaunchedEffect
}
val request = ImageRequest.Builder(context)
.data(url)
.size(48, 27)
.allowHardware(false)
.build()
val result = context.imageLoader.execute(request)
val source = ((result as? SuccessResult)?.drawable as? BitmapDrawable)?.bitmap
?: return@LaunchedEffect
val out = Bitmap.createBitmap(GRID_W, GRID_H, Bitmap.Config.ARGB_8888)
val targetRatio = GRID_W.toFloat() / GRID_H
val sourceRatio = source.width.toFloat() / source.height
val srcRect = if (sourceRatio > targetRatio) {
val w = (source.height * targetRatio).toInt()
Rect((source.width - w) / 2, 0, (source.width + w) / 2, source.height)
} else {
val h = (source.width / targetRatio).toInt()
Rect(0, (source.height - h) / 2, source.width, (source.height + h) / 2)
}
Canvas(out).drawBitmap(source, srcRect, Rect(0, 0, GRID_W, GRID_H), null)
val pixels = IntArray(GRID_W * GRID_H)
out.getPixels(pixels, 0, GRID_W, 0, 0, GRID_W, GRID_H)
VideoTintState.frame = VideoFrameColors(pixels, GRID_W, GRID_H)
}
}

View File

@@ -0,0 +1,545 @@
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.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.focus.focusRestorer
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import tv.vyla.app.ui.components.ContentFocusBridge
import tv.vyla.app.ui.components.BackdropTintSampler
import tv.vyla.app.ui.components.videoTint
import androidx.compose.foundation.lazy.itemsIndexed
import tv.vyla.app.ui.components.AmbientBackdrop
import tv.vyla.app.ui.components.HeroFadeStops
import tv.vyla.app.ui.components.verticalFadeMask
data class GenreRow(
val title: String,
val mediaType: String,
val genreId: Int,
val items: List<MediaItem>
)
@Composable
@OptIn(ExperimentalComposeUiApi::class)
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() }
val currentMode by rememberUpdatedState(mode)
var loadGeneration by remember { mutableIntStateOf(0) }
val listState = rememberLazyListState()
val cardRequesters = remember { mutableMapOf<String, FocusRequester>() }
var lastFocusedKey by remember { mutableStateOf<String?>(null) }
var ambientUrl by remember { mutableStateOf<String?>(null) }
var lastRowIndex by remember { mutableIntStateOf(-1) }
fun updateHero(item: MediaItem) {
heroJob?.cancel()
if (heroItem?.id == item.id && heroItem?.mediaType == item.mediaType) return
heroJob = coroutineScope.launch {
delay(200)
heroItem = item
ambientUrl = item.ambientBackdropUrl
heroLogoUrl = TmdbApi.getLogo(item.mediaType, item.id)
}
}
fun pinAmbientToHero() {
heroItem?.let { ambientUrl = it.ambientBackdropUrl }
}
suspend fun loadMoreRows(count: Int) {
if (isLoadingMore || rows.size >= 48) return
isLoadingMore = true
val generation = loadGeneration
val activeMode = currentMode
try {
val isMovieMode = activeMode == NavDestination.MOVIES
val isTvMode = activeMode == NavDestination.TV_SHOWS
val isAnimeMode = activeMode == NavDestination.ANIME
val isDiscover = activeMode == NavDestination.DISCOVERY
var added = 0
var attempts = 0
while (added < count && attempts < count * 4) {
attempts++
val curI = rowIdx++
val isMovie = when {
isMovieMode -> true
isTvMode -> false
isAnimeMode -> false
isDiscover -> curI % 2 == 0
else -> true
}
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 = if (isAnimeMode) {
TmdbApi.getAnimeDiscover(genre.id, page)
} else {
TmdbApi.getDiscover(mt, genre.id, page)
}
if (generation != loadGeneration) return
if (items.isNotEmpty()) {
val title = when {
isAnimeMode -> "${genre.name} Anime"
else -> "${genre.name} ${if (isMovie) "Movies" else "TV Shows"}"
}
rows.add(GenreRow(title, mt, genre.id, items))
added++
if (heroItem == null) {
val first = rows[0].items.firstOrNull()
if (first != null) {
heroItem = first
ambientUrl = first.ambientBackdropUrl
coroutineScope.launch {
heroLogoUrl = TmdbApi.getLogo(first.mediaType, first.id)
}
}
}
}
}
} finally {
if (generation == loadGeneration) isLoadingMore = false
}
}
fun restoreContentFocus(): Boolean {
val cardRequester = lastFocusedKey?.let { cardRequesters[it] }
if (cardRequester != null) {
try {
cardRequester.requestFocus()
return true
} catch (_: Exception) {}
}
return try {
playButtonFocusRequester.requestFocus()
true
} catch (_: Exception) {
false
}
}
DisposableEffect(Unit) {
val restore: () -> Boolean = { restoreContentFocus() }
ContentFocusBridge.restore = restore
onDispose {
if (ContentFocusBridge.restore === restore) ContentFocusBridge.restore = null
}
}
LaunchedEffect(mode) {
loadGeneration++
isLoadingMore = false
lastFocusedKey = null
cardRequesters.clear()
rows.clear()
rowIdx = 0
heroItem = null
ambientUrl = null
lastRowIndex = -1
heroLogoUrl = null
movieGenres.clear()
tvGenres.clear()
listState.scrollToItem(0)
movieGenres.addAll(TmdbApi.getGenreList("movie"))
tvGenres.addAll(TmdbApi.getGenreList("tv"))
loadMoreRows(5)
withTimeoutOrNull(10000) { snapshotFlow { heroItem }.first { it != null } }
delay(150)
var tries = 0
var focused = false
while (!focused && tries < 10 && lastFocusedKey == null) {
tries++
try {
playButtonFocusRequester.requestFocus()
focused = true
} catch (_: Exception) {
delay(100)
}
}
}
BackdropTintSampler(heroItem?.ambientBackdropUrl)
Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
AmbientBackdrop(
url = ambientUrl,
modifier = Modifier.fillMaxSize(),
durationMs = 700
)
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 = {
lastFocusedKey = null
lastRowIndex = -1
pinAmbientToHero()
coroutineScope.launch {
listState.animateScrollToItem(0)
}
},
onPlay = {
coroutineScope.launch { listState.scrollToItem(0) }
heroItem?.let(onPlay)
},
onInfo = {
coroutineScope.launch { listState.scrollToItem(0) }
heroItem?.let(onOpenDetail)
}
)
}
itemsIndexed(rows, key = { i, row -> "$i:${row.title}" }) { rowIndex, row ->
val rowState = rememberLazyListState()
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(
state = rowState,
contentPadding = PaddingValues(horizontal = 78.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
itemsIndexed(row.items, key = { _, it -> it.id }) { index, item ->
val isFirstInRow = index == 0
val cardKey = "$rowIndex:${item.id}"
val cardRequester = cardRequesters.getOrPut(cardKey) { FocusRequester() }
MediaCard(
item = item,
width = 136,
onSelect = { onOpenDetail(item) },
onFocused = {
lastFocusedKey = cardKey
updateHero(item)
coroutineScope.launch {
val viewportWidth = rowState.layoutInfo.viewportEndOffset - rowState.layoutInfo.viewportStartOffset
rowState.animateScrollToItem(
index,
-(viewportWidth / 2 - 68)
)
}
if (lastRowIndex != rowIndex) {
lastRowIndex = rowIndex
coroutineScope.launch {
val viewportHeight = listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset
listState.animateScrollToItem(
rowIndex + 1,
-(viewportHeight / 2 - 100)
)
}
}
},
modifier = Modifier.focusRequester(cardRequester).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)
) {
Box(
modifier = Modifier
.fillMaxSize()
.verticalFadeMask(*HeroFadeStops)
) {
val backdropUrl = item?.backdropUrl
if (!backdropUrl.isNullOrEmpty()) {
AsyncImage(
model = backdropUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
}
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 primaryBackground = if (isFocused) Color.White else Color(0xD9FFFFFF)
val contentColor = when {
isPrimary -> Color.Black
isFocused -> Color.Black
else -> Color.White
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.height(48.dp)
.clip(RoundedCornerShape(24.dp))
.then(
if (isPrimary) Modifier.background(primaryBackground) else Modifier.videoTint(isFocused)
)
.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,115 @@
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
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
import tv.vyla.app.ui.components.AmbientBackdrop
@Composable
fun LibraryScreen(
title: String,
items: List<MediaItem>,
onOpenDetail: (MediaItem) -> Unit,
modifier: Modifier = Modifier,
emptyMessage: String = "Nothing here yet. Open a title, press the ⋮ button and add it."
) {
var ambientUrl by remember(items) { mutableStateOf(items.firstOrNull()?.ambientBackdropUrl) }
Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
AmbientBackdrop(
url = ambientUrl,
modifier = Modifier.fillMaxSize(),
durationMs = 700
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0.0f to Color(0x0D000000),
0.55f to Color(0x8C000000),
1.0f to Color(0xF207070A)
)
)
)
Column(
modifier = Modifier
.fillMaxSize()
.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 = emptyMessage,
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) },
onFocused = { ambientUrl = it.ambientBackdropUrl }
)
}
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,635 @@
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.automirrored.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.*
import androidx.compose.ui.graphics.vector.ImageVector
import tv.vyla.app.ui.components.SearchIcons
import tv.vyla.app.ui.components.AmbientBackdrop
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.graphicsLayer
@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
}
) {
AmbientBackdrop(
url = focusedItem?.ambientBackdropUrl,
modifier = Modifier.fillMaxSize()
)
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 = SearchIcons.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)
.drawBehind { drawRect(Color.White, alpha = cursorAlpha) }
)
}
}
}
}
item {
val keyboardState = rememberLazyListState()
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) }
},
onFocus = {
coroutineScope.launch { listState.animateScrollToItem(0) }
}
)
SearchKeyButton(
label = "",
isWord = true,
icon = SearchIcons.Space,
description = "Space",
onClick = {
if (query.isNotEmpty() && !query.endsWith(" ")) {
query += " "
performSearch(query)
}
coroutineScope.launch { listState.animateScrollToItem(0) }
},
onFocus = {
coroutineScope.launch { listState.animateScrollToItem(0) }
}
)
LazyRow(
state = keyboardState,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f)
) {
val activeKeys = if (isNumbersMode) numbersAndSymbols else letters
items(activeKeys.size) { idx ->
val keyChar = activeKeys[idx]
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) }
},
onFocus = {
coroutineScope.launch {
val viewportWidth = keyboardState.layoutInfo.viewportEndOffset - keyboardState.layoutInfo.viewportStartOffset
keyboardState.animateScrollToItem(
idx,
-(viewportWidth / 2 - 20)
)
}
}
)
}
}
SearchKeyButton(
label = "",
isWord = false,
icon = SearchIcons.Backspace,
description = "Backspace",
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) }
},
onFocus = {
coroutineScope.launch { listState.animateScrollToItem(0) }
}
)
}
}
}
}
item {
val tabs = if (query.isNotBlank()) {
listOf("Top Results", "Movies", "TV Shows")
} else {
listOf("Top Results")
}
LaunchedEffect(query.isBlank()) {
if (query.isBlank() && selectedTab != 0) {
selectedTab = 0
}
}
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 = if (tabs.size > 1) Arrangement.SpaceAround else Arrangement.Center
) {
tabs.forEachIndexed { idx, title ->
SearchTabButton(
title = title,
isSelected = selectedTab == idx,
onClick = {
selectedTab = idx
coroutineScope.launch { listState.animateScrollToItem(0) }
},
onFocus = {
coroutineScope.launch { listState.animateScrollToItem(0) }
}
)
}
}
}
}
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,
icon: ImageVector? = null,
description: String? = null,
focusRequester: FocusRequester? = null,
onLeftPress: (() -> Unit)? = null,
onClick: () -> Unit,
onFocus: () -> 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
if (it.isFocused) onFocus()
}
.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 (icon != null) {
Icon(
imageVector = icon,
contentDescription = description,
tint = contentColor,
modifier = Modifier.size(if (isWord) 22.dp else 20.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,
onFocus: () -> 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
if (it.isFocused) onFocus()
}
.focusable()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onClick() }
.padding(horizontal = 20.dp)
) {
if (showSearchIcon) {
Icon(
imageVector = SearchIcons.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,
onFocus: () -> 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
if (it.isFocused) onFocus()
}
.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
)
}
}
@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)
.graphicsLayer {
scaleX = scale
scaleY = 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,77 @@
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
import androidx.compose.ui.graphics.graphicsLayer
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
.graphicsLayer {
scaleX = scale
scaleY = 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,18 +0,0 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

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>`
}
};

1525
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,14 @@
{
"name": "tv",
"version": "1.0.2",
"version": "1.0.6",
"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",
"@capacitor/cli": "^6.2.2",
"@capacitor/core": "^6.2.2",
"@vyla-entertainment/sdk": "^1.14.0",
"dotenv": "^16.4.5",
"express": "^4.19.2",

View File

@@ -1,6 +1,6 @@
{
"name": "Vyla TV",
"version": "1.0.0",
"package": "Vyla-TV_1.0.0.apk",
"releaseDate": "2026-10-05T04:59:18.576Z"
"version": "1.0.5",
"package": "Vyla-TV_1.0.5.apk",
"releaseDate": "2026-10-05T22:49:51.172Z"
}

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

View File

@@ -38,7 +38,6 @@ const version = pkg.version || '1.0.0';
const tag = `v${version}`;
run('node', ['scripts/build-android.mjs']);
run('npx', ['cap', 'sync', 'android']);
run(win ? 'gradlew.bat' : './gradlew', ['assembleRelease'], androidDir);
const relDir = path.join(androidDir, 'app/build/outputs/apk/release');

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>`
}
};