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