diff --git a/DOCUMENTATION/FORDEV.md b/DOCUMENTATION/FORDEV.md index 9605080..36afe54 100644 --- a/DOCUMENTATION/FORDEV.md +++ b/DOCUMENTATION/FORDEV.md @@ -1,7 +1,12 @@ -To apply changes to the app, run `npm run dev` to build the APK and `npm run android` to sync the changes to the android project. +To build the native Android TV app APK, run: +```bash +npm run dev +``` +This builds the Vyla JS resolver asset and compiles the native Android TV app via `./gradlew assembleDebug` to produce `vyla.apk`. -To release a new version, run +To open and develop in Android Studio, open the `android` folder directly. +To release a new version: ```bash npm version patch git push origin main diff --git a/android/app/build.gradle b/android/app/build.gradle index b8c1fe4..f87484b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,21 +1,20 @@ apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-parcelize' android { namespace "tv.vyla.app" - compileSdk rootProject.ext.compileSdkVersion + compileSdk 34 + defaultConfig { applicationId "tv.vyla.app" - minSdkVersion rootProject.ext.minSdkVersion - targetSdkVersion rootProject.ext.targetSdkVersion + minSdkVersion 24 + targetSdkVersion 34 versionCode 1 - versionName "1.0" + versionName "1.0.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - aaptOptions { - // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. - // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 - ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' - } } + buildTypes { release { signingConfig signingConfigs.debug @@ -23,35 +22,58 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } -} -repositories { - flatDir{ - dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion '1.5.10' } } dependencies { - implementation fileTree(include: ['*.jar'], dir: 'libs') - implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" - implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" - implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" - implementation project(':capacitor-android') - testImplementation "junit:junit:$junitVersion" - androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" - androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" - implementation project(':capacitor-cordova-android-plugins') + implementation "androidx.core:core-ktx:1.12.0" + implementation "androidx.appcompat:appcompat:1.6.1" + implementation "androidx.activity:activity-compose:1.8.2" + + implementation platform("androidx.compose:compose-bom:2024.02.02") + implementation "androidx.compose.ui:ui" + implementation "androidx.compose.ui:ui-graphics" + implementation "androidx.compose.ui:ui-tooling-preview" + implementation "androidx.compose.material3:material3" + implementation "androidx.compose.material:material-icons-extended" + + implementation "androidx.tv:tv-material:1.0.0" + + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.7.0" + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0" + implementation "androidx.lifecycle:lifecycle-runtime-compose:2.7.0" + + implementation "io.coil-kt:coil-compose:2.6.0" + + implementation "androidx.media3:media3-exoplayer:1.3.1" + implementation "androidx.media3:media3-exoplayer-hls:1.3.1" + implementation "androidx.media3:media3-ui:1.3.1" + implementation "androidx.media3:media3-common:1.3.1" + implementation "androidx.media3:media3-session:1.3.1" + implementation "com.squareup.okhttp3:okhttp:4.12.0" + implementation "com.squareup.okhttp3:logging-interceptor:4.12.0" implementation "org.nanohttpd:nanohttpd:2.3.1" -} -apply from: 'capacitor.build.gradle' + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0" -try { - def servicesJSON = file('google-services.json') - if (servicesJSON.text) { - apply plugin: 'com.google.gms.google-services' - } -} catch(Exception e) { - logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") + testImplementation "junit:junit:4.13.2" + androidTestImplementation "androidx.test.ext:junit:1.1.5" + androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1" } diff --git a/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java index f2c2217..8824bed 100644 --- a/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java +++ b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -18,7 +18,6 @@ public class ExampleInstrumentedTest { @Test public void useAppContext() throws Exception { - // Context of the app under test. Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); assertEquals("com.getcapacitor.app", appContext.getPackageName()); diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5c8716f..e952955 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,12 +7,13 @@ @@ -21,7 +22,7 @@ android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode" android:name=".MainActivity" android:label="@string/title_activity_main" - android:theme="@style/AppTheme.NoActionBarLaunch" + android:theme="@style/AppTheme.NoActionBar" android:banner="@drawable/tv_banner" android:launchMode="singleTask" android:exported="true" @@ -34,19 +35,5 @@ - - - - - - - - diff --git a/android/app/src/main/assets/vyla-local.js b/android/app/src/main/assets/vyla-local.js new file mode 100644 index 0000000..ffbe885 --- /dev/null +++ b/android/app/src/main/assets/vyla-local.js @@ -0,0 +1,18 @@ +(()=>{var Dn=Object.create;var $e=Object.defineProperty;var Hn=Object.getOwnPropertyDescriptor;var qn=Object.getOwnPropertyNames;var Kn=Object.getPrototypeOf,zn=Object.prototype.hasOwnProperty;var Pr=r=>t=>{var e=r[t];if(e)return e();throw new Error("Module not found in bundle: "+t)};var F=(r,t)=>()=>(r&&(t=r(r=0)),t);var Be=(r,t)=>()=>(t||r((t={exports:{}}).exports,t),t.exports),Q=(r,t)=>{for(var e in t)$e(r,e,{get:t[e],enumerable:!0})},Gn=(r,t,e,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let i of qn(t))!zn.call(r,i)&&i!==e&&$e(r,i,{get:()=>t[i],enumerable:!(n=Hn(t,i))||n.enumerable});return r};var Vn=(r,t,e)=>(e=r!=null?Dn(Kn(r)):{},Gn(t||!r||!r.__esModule?$e(e,"default",{value:r,enumerable:!0}):e,r));var Nr=Be(It=>{"use strict";$();It.byteLength=Jn;It.toByteArray=Wn;It.fromByteArray=ei;var X=[],G=[],Xn=typeof Uint8Array<"u"?Uint8Array:Array,ve="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";for(nt=0,Lr=ve.length;nt0)throw new Error("Invalid string. Length must be a multiple of 4");var e=r.indexOf("=");e===-1&&(e=t);var n=e===t?0:4-e%4;return[e,n]}function Jn(r){var t=Or(r),e=t[0],n=t[1];return(e+n)*3/4-n}function Yn(r,t,e){return(t+e)*3/4-e}function Wn(r){var t,e=Or(r),n=e[0],i=e[1],s=new Xn(Yn(r,n,i)),o=0,l=i>0?n-4:n,a;for(a=0;a>16&255,s[o++]=t>>8&255,s[o++]=t&255;return i===2&&(t=G[r.charCodeAt(a)]<<2|G[r.charCodeAt(a+1)]>>4,s[o++]=t&255),i===1&&(t=G[r.charCodeAt(a)]<<10|G[r.charCodeAt(a+1)]<<4|G[r.charCodeAt(a+2)]>>2,s[o++]=t>>8&255,s[o++]=t&255),s}function Zn(r){return X[r>>18&63]+X[r>>12&63]+X[r>>6&63]+X[r&63]}function ti(r,t,e){for(var n,i=[],s=t;sl?l:o+s));return n===1?(t=r[e-1],i.push(X[t>>2]+X[t<<4&63]+"==")):n===2&&(t=(r[e-2]<<8)+r[e-1],i.push(X[t>>10]+X[t>>4&63]+X[t<<2&63]+"=")),i.join("")}});var jr=Be(Ue=>{$();Ue.read=function(r,t,e,n,i){var s,o,l=i*8-n-1,a=(1<>1,f=-7,u=e?i-1:0,p=e?-1:1,m=r[t+u];for(u+=p,s=m&(1<<-f)-1,m>>=-f,f+=l;f>0;s=s*256+r[t+u],u+=p,f-=8);for(o=s&(1<<-f)-1,s>>=-f,f+=n;f>0;o=o*256+r[t+u],u+=p,f-=8);if(s===0)s=1-c;else{if(s===a)return o?NaN:(m?-1:1)*(1/0);o=o+Math.pow(2,n),s=s-c}return(m?-1:1)*o*Math.pow(2,s-n)};Ue.write=function(r,t,e,n,i,s){var o,l,a,c=s*8-i-1,f=(1<>1,p=i===23?Math.pow(2,-24)-Math.pow(2,-77):0,m=n?0:s-1,h=n?1:-1,A=t<0||t===0&&1/t<0?1:0;for(t=Math.abs(t),isNaN(t)||t===1/0?(l=isNaN(t)?1:0,o=f):(o=Math.floor(Math.log(t)/Math.LN2),t*(a=Math.pow(2,-o))<1&&(o--,a*=2),o+u>=1?t+=p/a:t+=p*Math.pow(2,1-u),t*a>=2&&(o++,a/=2),o+u>=f?(l=0,o=f):o+u>=1?(l=(t*a-1)*Math.pow(2,i),o=o+u):(l=t*Math.pow(2,u-1)*Math.pow(2,i),o=0));i>=8;r[e+m]=l&255,m+=h,l/=256,i-=8);for(o=o<0;r[e+m]=o&255,m+=h,o/=256,c-=8);r[e+m-h]|=A*128}});var sn=Be(pt=>{"use strict";$();var Re=Nr(),ut=jr(),Dr=typeof Symbol=="function"&&typeof Symbol.for=="function"?Symbol.for("nodejs.util.inspect.custom"):null;pt.Buffer=g;pt.SlowBuffer=ai;pt.INSPECT_MAX_BYTES=50;var $t=2147483647;pt.kMaxLength=$t;g.TYPED_ARRAY_SUPPORT=ri();!g.TYPED_ARRAY_SUPPORT&&typeof console<"u"&&typeof console.error=="function"&&console.error("This browser lacks typed array (Uint8Array) support which is required by `buffer` v5.x. Use `buffer` v4.x if you require old browser support.");function ri(){try{let r=new Uint8Array(1),t={foo:function(){return 42}};return Object.setPrototypeOf(t,Uint8Array.prototype),Object.setPrototypeOf(r,t),r.foo()===42}catch{return!1}}Object.defineProperty(g.prototype,"parent",{enumerable:!0,get:function(){if(g.isBuffer(this))return this.buffer}});Object.defineProperty(g.prototype,"offset",{enumerable:!0,get:function(){if(g.isBuffer(this))return this.byteOffset}});function W(r){if(r>$t)throw new RangeError('The value "'+r+'" is invalid for option "size"');let t=new Uint8Array(r);return Object.setPrototypeOf(t,g.prototype),t}function g(r,t,e){if(typeof r=="number"){if(typeof t=="string")throw new TypeError('The "string" argument must be of type string. Received type number');return Fe(r)}return zr(r,t,e)}g.poolSize=8192;function zr(r,t,e){if(typeof r=="string")return ii(r,t);if(ArrayBuffer.isView(r))return si(r);if(r==null)throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof r);if(J(r,ArrayBuffer)||r&&J(r.buffer,ArrayBuffer)||typeof SharedArrayBuffer<"u"&&(J(r,SharedArrayBuffer)||r&&J(r.buffer,SharedArrayBuffer)))return ke(r,t,e);if(typeof r=="number")throw new TypeError('The "value" argument must not be of type number. Received type number');let n=r.valueOf&&r.valueOf();if(n!=null&&n!==r)return g.from(n,t,e);let i=oi(r);if(i)return i;if(typeof Symbol<"u"&&Symbol.toPrimitive!=null&&typeof r[Symbol.toPrimitive]=="function")return g.from(r[Symbol.toPrimitive]("string"),t,e);throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof r)}g.from=function(r,t,e){return zr(r,t,e)};Object.setPrototypeOf(g.prototype,Uint8Array.prototype);Object.setPrototypeOf(g,Uint8Array);function Gr(r){if(typeof r!="number")throw new TypeError('"size" argument must be of type number');if(r<0)throw new RangeError('The value "'+r+'" is invalid for option "size"')}function ni(r,t,e){return Gr(r),r<=0?W(r):t!==void 0?typeof e=="string"?W(r).fill(t,e):W(r).fill(t):W(r)}g.alloc=function(r,t,e){return ni(r,t,e)};function Fe(r){return Gr(r),W(r<0?0:Te(r)|0)}g.allocUnsafe=function(r){return Fe(r)};g.allocUnsafeSlow=function(r){return Fe(r)};function ii(r,t){if((typeof t!="string"||t==="")&&(t="utf8"),!g.isEncoding(t))throw new TypeError("Unknown encoding: "+t);let e=Vr(r,t)|0,n=W(e),i=n.write(r,t);return i!==e&&(n=n.slice(0,i)),n}function Ce(r){let t=r.length<0?0:Te(r.length)|0,e=W(t);for(let n=0;n=$t)throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+$t.toString(16)+" bytes");return r|0}function ai(r){return+r!=r&&(r=0),g.alloc(+r)}g.isBuffer=function(t){return t!=null&&t._isBuffer===!0&&t!==g.prototype};g.compare=function(t,e){if(J(t,Uint8Array)&&(t=g.from(t,t.offset,t.byteLength)),J(e,Uint8Array)&&(e=g.from(e,e.offset,e.byteLength)),!g.isBuffer(t)||!g.isBuffer(e))throw new TypeError('The "buf1", "buf2" arguments must be one of type Buffer or Uint8Array');if(t===e)return 0;let n=t.length,i=e.length;for(let s=0,o=Math.min(n,i);si.length?(g.isBuffer(o)||(o=g.from(o)),o.copy(i,s)):Uint8Array.prototype.set.call(i,o,s);else if(g.isBuffer(o))o.copy(i,s);else throw new TypeError('"list" argument must be an Array of Buffers');s+=o.length}return i};function Vr(r,t){if(g.isBuffer(r))return r.length;if(ArrayBuffer.isView(r)||J(r,ArrayBuffer))return r.byteLength;if(typeof r!="string")throw new TypeError('The "string" argument must be one of type string, Buffer, or ArrayBuffer. Received type '+typeof r);let e=r.length,n=arguments.length>2&&arguments[2]===!0;if(!n&&e===0)return 0;let i=!1;for(;;)switch(t){case"ascii":case"latin1":case"binary":return e;case"utf8":case"utf-8":return _e(r).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return e*2;case"hex":return e>>>1;case"base64":return nn(r).length;default:if(i)return n?-1:_e(r).length;t=(""+t).toLowerCase(),i=!0}}g.byteLength=Vr;function li(r,t,e){let n=!1;if((t===void 0||t<0)&&(t=0),t>this.length||((e===void 0||e>this.length)&&(e=this.length),e<=0)||(e>>>=0,t>>>=0,e<=t))return"";for(r||(r="utf8");;)switch(r){case"hex":return yi(this,t,e);case"utf8":case"utf-8":return Jr(this,t,e);case"ascii":return di(this,t,e);case"latin1":case"binary":return gi(this,t,e);case"base64":return mi(this,t,e);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return wi(this,t,e);default:if(n)throw new TypeError("Unknown encoding: "+r);r=(r+"").toLowerCase(),n=!0}}g.prototype._isBuffer=!0;function it(r,t,e){let n=r[t];r[t]=r[e],r[e]=n}g.prototype.swap16=function(){let t=this.length;if(t%2!==0)throw new RangeError("Buffer size must be a multiple of 16-bits");for(let e=0;ee&&(t+=" ... "),""};Dr&&(g.prototype[Dr]=g.prototype.inspect);g.prototype.compare=function(t,e,n,i,s){if(J(t,Uint8Array)&&(t=g.from(t,t.offset,t.byteLength)),!g.isBuffer(t))throw new TypeError('The "target" argument must be one of type Buffer or Uint8Array. Received type '+typeof t);if(e===void 0&&(e=0),n===void 0&&(n=t?t.length:0),i===void 0&&(i=0),s===void 0&&(s=this.length),e<0||n>t.length||i<0||s>this.length)throw new RangeError("out of range index");if(i>=s&&e>=n)return 0;if(i>=s)return-1;if(e>=n)return 1;if(e>>>=0,n>>>=0,i>>>=0,s>>>=0,this===t)return 0;let o=s-i,l=n-e,a=Math.min(o,l),c=this.slice(i,s),f=t.slice(e,n);for(let u=0;u2147483647?e=2147483647:e<-2147483648&&(e=-2147483648),e=+e,Me(e)&&(e=i?0:r.length-1),e<0&&(e=r.length+e),e>=r.length){if(i)return-1;e=r.length-1}else if(e<0)if(i)e=0;else return-1;if(typeof t=="string"&&(t=g.from(t,n)),g.isBuffer(t))return t.length===0?-1:Hr(r,t,e,n,i);if(typeof t=="number")return t=t&255,typeof Uint8Array.prototype.indexOf=="function"?i?Uint8Array.prototype.indexOf.call(r,t,e):Uint8Array.prototype.lastIndexOf.call(r,t,e):Hr(r,[t],e,n,i);throw new TypeError("val must be string, number or Buffer")}function Hr(r,t,e,n,i){let s=1,o=r.length,l=t.length;if(n!==void 0&&(n=String(n).toLowerCase(),n==="ucs2"||n==="ucs-2"||n==="utf16le"||n==="utf-16le")){if(r.length<2||t.length<2)return-1;s=2,o/=2,l/=2,e/=2}function a(f,u){return s===1?f[u]:f.readUInt16BE(u*s)}let c;if(i){let f=-1;for(c=e;co&&(e=o-l),c=e;c>=0;c--){let f=!0;for(let u=0;ui&&(n=i)):n=i;let s=t.length;n>s/2&&(n=s/2);let o;for(o=0;o>>0,isFinite(n)?(n=n>>>0,i===void 0&&(i="utf8")):(i=n,n=void 0);else throw new Error("Buffer.write(string, encoding, offset[, length]) is no longer supported");let s=this.length-e;if((n===void 0||n>s)&&(n=s),t.length>0&&(n<0||e<0)||e>this.length)throw new RangeError("Attempt to write outside buffer bounds");i||(i="utf8");let o=!1;for(;;)switch(i){case"hex":return ci(this,t,e,n);case"utf8":case"utf-8":return ui(this,t,e,n);case"ascii":case"latin1":case"binary":return fi(this,t,e,n);case"base64":return pi(this,t,e,n);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return hi(this,t,e,n);default:if(o)throw new TypeError("Unknown encoding: "+i);i=(""+i).toLowerCase(),o=!0}};g.prototype.toJSON=function(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};function mi(r,t,e){return t===0&&e===r.length?Re.fromByteArray(r):Re.fromByteArray(r.slice(t,e))}function Jr(r,t,e){e=Math.min(r.length,e);let n=[],i=t;for(;i239?4:s>223?3:s>191?2:1;if(i+l<=e){let a,c,f,u;switch(l){case 1:s<128&&(o=s);break;case 2:a=r[i+1],(a&192)===128&&(u=(s&31)<<6|a&63,u>127&&(o=u));break;case 3:a=r[i+1],c=r[i+2],(a&192)===128&&(c&192)===128&&(u=(s&15)<<12|(a&63)<<6|c&63,u>2047&&(u<55296||u>57343)&&(o=u));break;case 4:a=r[i+1],c=r[i+2],f=r[i+3],(a&192)===128&&(c&192)===128&&(f&192)===128&&(u=(s&15)<<18|(a&63)<<12|(c&63)<<6|f&63,u>65535&&u<1114112&&(o=u))}}o===null?(o=65533,l=1):o>65535&&(o-=65536,n.push(o>>>10&1023|55296),o=56320|o&1023),n.push(o),i+=l}return Ai(n)}var qr=4096;function Ai(r){let t=r.length;if(t<=qr)return String.fromCharCode.apply(String,r);let e="",n=0;for(;nn)&&(e=n);let i="";for(let s=t;sn&&(t=n),e<0?(e+=n,e<0&&(e=0)):e>n&&(e=n),ee)throw new RangeError("Trying to access beyond buffer length")}g.prototype.readUintLE=g.prototype.readUIntLE=function(t,e,n){t=t>>>0,e=e>>>0,n||N(t,e,this.length);let i=this[t],s=1,o=0;for(;++o>>0,e=e>>>0,n||N(t,e,this.length);let i=this[t+--e],s=1;for(;e>0&&(s*=256);)i+=this[t+--e]*s;return i};g.prototype.readUint8=g.prototype.readUInt8=function(t,e){return t=t>>>0,e||N(t,1,this.length),this[t]};g.prototype.readUint16LE=g.prototype.readUInt16LE=function(t,e){return t=t>>>0,e||N(t,2,this.length),this[t]|this[t+1]<<8};g.prototype.readUint16BE=g.prototype.readUInt16BE=function(t,e){return t=t>>>0,e||N(t,2,this.length),this[t]<<8|this[t+1]};g.prototype.readUint32LE=g.prototype.readUInt32LE=function(t,e){return t=t>>>0,e||N(t,4,this.length),(this[t]|this[t+1]<<8|this[t+2]<<16)+this[t+3]*16777216};g.prototype.readUint32BE=g.prototype.readUInt32BE=function(t,e){return t=t>>>0,e||N(t,4,this.length),this[t]*16777216+(this[t+1]<<16|this[t+2]<<8|this[t+3])};g.prototype.readBigUInt64LE=tt(function(t){t=t>>>0,ft(t,"offset");let e=this[t],n=this[t+7];(e===void 0||n===void 0)&&yt(t,this.length-8);let i=e+this[++t]*2**8+this[++t]*2**16+this[++t]*2**24,s=this[++t]+this[++t]*2**8+this[++t]*2**16+n*2**24;return BigInt(i)+(BigInt(s)<>>0,ft(t,"offset");let e=this[t],n=this[t+7];(e===void 0||n===void 0)&&yt(t,this.length-8);let i=e*2**24+this[++t]*2**16+this[++t]*2**8+this[++t],s=this[++t]*2**24+this[++t]*2**16+this[++t]*2**8+n;return(BigInt(i)<>>0,e=e>>>0,n||N(t,e,this.length);let i=this[t],s=1,o=0;for(;++o=s&&(i-=Math.pow(2,8*e)),i};g.prototype.readIntBE=function(t,e,n){t=t>>>0,e=e>>>0,n||N(t,e,this.length);let i=e,s=1,o=this[t+--i];for(;i>0&&(s*=256);)o+=this[t+--i]*s;return s*=128,o>=s&&(o-=Math.pow(2,8*e)),o};g.prototype.readInt8=function(t,e){return t=t>>>0,e||N(t,1,this.length),this[t]&128?(255-this[t]+1)*-1:this[t]};g.prototype.readInt16LE=function(t,e){t=t>>>0,e||N(t,2,this.length);let n=this[t]|this[t+1]<<8;return n&32768?n|4294901760:n};g.prototype.readInt16BE=function(t,e){t=t>>>0,e||N(t,2,this.length);let n=this[t+1]|this[t]<<8;return n&32768?n|4294901760:n};g.prototype.readInt32LE=function(t,e){return t=t>>>0,e||N(t,4,this.length),this[t]|this[t+1]<<8|this[t+2]<<16|this[t+3]<<24};g.prototype.readInt32BE=function(t,e){return t=t>>>0,e||N(t,4,this.length),this[t]<<24|this[t+1]<<16|this[t+2]<<8|this[t+3]};g.prototype.readBigInt64LE=tt(function(t){t=t>>>0,ft(t,"offset");let e=this[t],n=this[t+7];(e===void 0||n===void 0)&&yt(t,this.length-8);let i=this[t+4]+this[t+5]*2**8+this[t+6]*2**16+(n<<24);return(BigInt(i)<>>0,ft(t,"offset");let e=this[t],n=this[t+7];(e===void 0||n===void 0)&&yt(t,this.length-8);let i=(e<<24)+this[++t]*2**16+this[++t]*2**8+this[++t];return(BigInt(i)<>>0,e||N(t,4,this.length),ut.read(this,t,!0,23,4)};g.prototype.readFloatBE=function(t,e){return t=t>>>0,e||N(t,4,this.length),ut.read(this,t,!1,23,4)};g.prototype.readDoubleLE=function(t,e){return t=t>>>0,e||N(t,8,this.length),ut.read(this,t,!0,52,8)};g.prototype.readDoubleBE=function(t,e){return t=t>>>0,e||N(t,8,this.length),ut.read(this,t,!1,52,8)};function q(r,t,e,n,i,s){if(!g.isBuffer(r))throw new TypeError('"buffer" argument must be a Buffer instance');if(t>i||tr.length)throw new RangeError("Index out of range")}g.prototype.writeUintLE=g.prototype.writeUIntLE=function(t,e,n,i){if(t=+t,e=e>>>0,n=n>>>0,!i){let l=Math.pow(2,8*n)-1;q(this,t,e,n,l,0)}let s=1,o=0;for(this[e]=t&255;++o>>0,n=n>>>0,!i){let l=Math.pow(2,8*n)-1;q(this,t,e,n,l,0)}let s=n-1,o=1;for(this[e+s]=t&255;--s>=0&&(o*=256);)this[e+s]=t/o&255;return e+n};g.prototype.writeUint8=g.prototype.writeUInt8=function(t,e,n){return t=+t,e=e>>>0,n||q(this,t,e,1,255,0),this[e]=t&255,e+1};g.prototype.writeUint16LE=g.prototype.writeUInt16LE=function(t,e,n){return t=+t,e=e>>>0,n||q(this,t,e,2,65535,0),this[e]=t&255,this[e+1]=t>>>8,e+2};g.prototype.writeUint16BE=g.prototype.writeUInt16BE=function(t,e,n){return t=+t,e=e>>>0,n||q(this,t,e,2,65535,0),this[e]=t>>>8,this[e+1]=t&255,e+2};g.prototype.writeUint32LE=g.prototype.writeUInt32LE=function(t,e,n){return t=+t,e=e>>>0,n||q(this,t,e,4,4294967295,0),this[e+3]=t>>>24,this[e+2]=t>>>16,this[e+1]=t>>>8,this[e]=t&255,e+4};g.prototype.writeUint32BE=g.prototype.writeUInt32BE=function(t,e,n){return t=+t,e=e>>>0,n||q(this,t,e,4,4294967295,0),this[e]=t>>>24,this[e+1]=t>>>16,this[e+2]=t>>>8,this[e+3]=t&255,e+4};function Yr(r,t,e,n,i){rn(t,n,i,r,e,7);let s=Number(t&BigInt(4294967295));r[e++]=s,s=s>>8,r[e++]=s,s=s>>8,r[e++]=s,s=s>>8,r[e++]=s;let o=Number(t>>BigInt(32)&BigInt(4294967295));return r[e++]=o,o=o>>8,r[e++]=o,o=o>>8,r[e++]=o,o=o>>8,r[e++]=o,e}function Wr(r,t,e,n,i){rn(t,n,i,r,e,7);let s=Number(t&BigInt(4294967295));r[e+7]=s,s=s>>8,r[e+6]=s,s=s>>8,r[e+5]=s,s=s>>8,r[e+4]=s;let o=Number(t>>BigInt(32)&BigInt(4294967295));return r[e+3]=o,o=o>>8,r[e+2]=o,o=o>>8,r[e+1]=o,o=o>>8,r[e]=o,e+8}g.prototype.writeBigUInt64LE=tt(function(t,e=0){return Yr(this,t,e,BigInt(0),BigInt("0xffffffffffffffff"))});g.prototype.writeBigUInt64BE=tt(function(t,e=0){return Wr(this,t,e,BigInt(0),BigInt("0xffffffffffffffff"))});g.prototype.writeIntLE=function(t,e,n,i){if(t=+t,e=e>>>0,!i){let a=Math.pow(2,8*n-1);q(this,t,e,n,a-1,-a)}let s=0,o=1,l=0;for(this[e]=t&255;++s>0)-l&255;return e+n};g.prototype.writeIntBE=function(t,e,n,i){if(t=+t,e=e>>>0,!i){let a=Math.pow(2,8*n-1);q(this,t,e,n,a-1,-a)}let s=n-1,o=1,l=0;for(this[e+s]=t&255;--s>=0&&(o*=256);)t<0&&l===0&&this[e+s+1]!==0&&(l=1),this[e+s]=(t/o>>0)-l&255;return e+n};g.prototype.writeInt8=function(t,e,n){return t=+t,e=e>>>0,n||q(this,t,e,1,127,-128),t<0&&(t=255+t+1),this[e]=t&255,e+1};g.prototype.writeInt16LE=function(t,e,n){return t=+t,e=e>>>0,n||q(this,t,e,2,32767,-32768),this[e]=t&255,this[e+1]=t>>>8,e+2};g.prototype.writeInt16BE=function(t,e,n){return t=+t,e=e>>>0,n||q(this,t,e,2,32767,-32768),this[e]=t>>>8,this[e+1]=t&255,e+2};g.prototype.writeInt32LE=function(t,e,n){return t=+t,e=e>>>0,n||q(this,t,e,4,2147483647,-2147483648),this[e]=t&255,this[e+1]=t>>>8,this[e+2]=t>>>16,this[e+3]=t>>>24,e+4};g.prototype.writeInt32BE=function(t,e,n){return t=+t,e=e>>>0,n||q(this,t,e,4,2147483647,-2147483648),t<0&&(t=4294967295+t+1),this[e]=t>>>24,this[e+1]=t>>>16,this[e+2]=t>>>8,this[e+3]=t&255,e+4};g.prototype.writeBigInt64LE=tt(function(t,e=0){return Yr(this,t,e,-BigInt("0x8000000000000000"),BigInt("0x7fffffffffffffff"))});g.prototype.writeBigInt64BE=tt(function(t,e=0){return Wr(this,t,e,-BigInt("0x8000000000000000"),BigInt("0x7fffffffffffffff"))});function Zr(r,t,e,n,i,s){if(e+n>r.length)throw new RangeError("Index out of range");if(e<0)throw new RangeError("Index out of range")}function tn(r,t,e,n,i){return t=+t,e=e>>>0,i||Zr(r,t,e,4,34028234663852886e22,-34028234663852886e22),ut.write(r,t,e,n,23,4),e+4}g.prototype.writeFloatLE=function(t,e,n){return tn(this,t,e,!0,n)};g.prototype.writeFloatBE=function(t,e,n){return tn(this,t,e,!1,n)};function en(r,t,e,n,i){return t=+t,e=e>>>0,i||Zr(r,t,e,8,17976931348623157e292,-17976931348623157e292),ut.write(r,t,e,n,52,8),e+8}g.prototype.writeDoubleLE=function(t,e,n){return en(this,t,e,!0,n)};g.prototype.writeDoubleBE=function(t,e,n){return en(this,t,e,!1,n)};g.prototype.copy=function(t,e,n,i){if(!g.isBuffer(t))throw new TypeError("argument should be a Buffer");if(n||(n=0),!i&&i!==0&&(i=this.length),e>=t.length&&(e=t.length),e||(e=0),i>0&&i=this.length)throw new RangeError("Index out of range");if(i<0)throw new RangeError("sourceEnd out of bounds");i>this.length&&(i=this.length),t.length-e>>0,n=n===void 0?this.length:n>>>0,t||(t=0);let s;if(typeof t=="number")for(s=e;s2**32?i=Kr(String(e)):typeof e=="bigint"&&(i=String(e),(e>BigInt(2)**BigInt(32)||e<-(BigInt(2)**BigInt(32)))&&(i=Kr(i)),i+="n"),n+=` It must be ${t}. Received ${i}`,n},RangeError);function Kr(r){let t="",e=r.length,n=r[0]==="-"?1:0;for(;e>=n+4;e-=3)t=`_${r.slice(e-3,e)}${t}`;return`${r.slice(0,e)}${t}`}function Ei(r,t,e){ft(t,"offset"),(r[t]===void 0||r[t+e]===void 0)&&yt(t,r.length-(e+1))}function rn(r,t,e,n,i,s){if(r>e||r3?t===0||t===BigInt(0)?l=`>= 0${o} and < 2${o} ** ${(s+1)*8}${o}`:l=`>= -(2${o} ** ${(s+1)*8-1}${o}) and < 2 ** ${(s+1)*8-1}${o}`:l=`>= ${t}${o} and <= ${e}${o}`,new ct.ERR_OUT_OF_RANGE("value",l,r)}Ei(n,i,s)}function ft(r,t){if(typeof r!="number")throw new ct.ERR_INVALID_ARG_TYPE(t,"number",r)}function yt(r,t,e){throw Math.floor(r)!==r?(ft(r,e),new ct.ERR_OUT_OF_RANGE(e||"offset","an integer",r)):t<0?new ct.ERR_BUFFER_OUT_OF_BOUNDS:new ct.ERR_OUT_OF_RANGE(e||"offset",`>= ${e?1:0} and <= ${t}`,r)}var xi=/[^+/0-9A-Za-z-_]/g;function bi(r){if(r=r.split("=")[0],r=r.trim().replace(xi,""),r.length<2)return"";for(;r.length%4!==0;)r=r+"=";return r}function _e(r,t){t=t||1/0;let e,n=r.length,i=null,s=[];for(let o=0;o55295&&e<57344){if(!i){if(e>56319){(t-=3)>-1&&s.push(239,191,189);continue}else if(o+1===n){(t-=3)>-1&&s.push(239,191,189);continue}i=e;continue}if(e<56320){(t-=3)>-1&&s.push(239,191,189),i=e;continue}e=(i-55296<<10|e-56320)+65536}else i&&(t-=3)>-1&&s.push(239,191,189);if(i=null,e<128){if((t-=1)<0)break;s.push(e)}else if(e<2048){if((t-=2)<0)break;s.push(e>>6|192,e&63|128)}else if(e<65536){if((t-=3)<0)break;s.push(e>>12|224,e>>6&63|128,e&63|128)}else if(e<1114112){if((t-=4)<0)break;s.push(e>>18|240,e>>12&63|128,e>>6&63|128,e&63|128)}else throw new Error("Invalid code point")}return s}function Si(r){let t=[];for(let e=0;e>8,i=e%256,s.push(i),s.push(n);return s}function nn(r){return Re.toByteArray(bi(r))}function Bt(r,t,e,n){let i;for(i=0;i=t.length||i>=r.length);++i)t[i+e]=r[i];return i}function J(r,t){return r instanceof t||r!=null&&r.constructor!=null&&r.constructor.name!=null&&r.constructor.name===t.name}function Me(r){return r!==r}var $i=(function(){let r="0123456789abcdef",t=new Array(256);for(let e=0;e<16;++e){let n=e*16;for(let i=0;i<16;++i)t[n+i]=r[e]+r[i]}return t})();function tt(r){return typeof BigInt>"u"?Bi:r}function Bi(){throw new Error("BigInt not supported")}});var R,$=F(()=>{R=Vn(sn(),1);globalThis.Buffer=globalThis.Buffer||R.Buffer;globalThis.process=globalThis.process||{env:{}};AbortSignal.timeout||(AbortSignal.timeout=r=>{let t=new AbortController;return setTimeout(()=>t.abort(),r),t.signal})});var Pe,Le=F(()=>{$();Pe=globalThis.crypto});async function k(r,t){let e=await fetch(r,t);if(!e.ok)throw new Error(`HTTP ${e.status}`);return e.json()}async function H(r,t){let e=await fetch(r,t);if(!e.ok)throw new Error(`HTTP ${e.status}`);return e.text()}function Oe(r){let t=Date.now();for(let[e,n]of r)t-n.ts>=n.ttl&&r.delete(e);if(r.size>3e3){let e=r.size-3e3,n=r.keys();for(let i=0;i=e.ttl){r.delete(t);return}return e.val}}function Ci(r,t,e,n){r.set(t,{val:e,ts:Date.now(),ttl:n})}async function ln(r,t,e,n,i,s=null){let o=/dub$/.test(r.key)?"dub":"sub";return{id:e,s:n,e:i,clientIP:s,absoluteBase:"/",audio:o,sdk:t,config:r}}async function K(r,t,e,n){var f,u,p;let i=r,s=t,o=e,l=n;i==="movie"||i==="tv"?(o=i,s=t,i=process.env.TMDB_API_KEY):(s==="movie"||s==="tv")&&(o=s,s=i,i=process.env.TMDB_API_KEY),i||(i=process.env.TMDB_API_KEY);let a=`${s}-${o}-${l||""}`,c=Ri(je,a);if(c!==void 0)return c;if(!i)return{isAnime:!1,titles:[],year:null,imdbId:null};try{let[m,h]=await Promise.all([fetch(`https://api.themoviedb.org/3/${o}/${s}?api_key=${i}&append_to_response=external_ids`,{signal:AbortSignal.timeout(5e3)}),l&&o==="tv"?fetch(`https://api.themoviedb.org/3/tv/${s}/season/${l}?api_key=${i}`,{signal:AbortSignal.timeout(5e3)}):Promise.resolve(null)]),A=null,d=null;m.ok?A=await m.json():(f=m.body)==null||f.cancel(),h&&(h.ok?d=await h.json():(u=h.body)==null||u.cancel());let I=((A==null?void 0:A.genres)||[]).some(E=>E.id===16)&&(((A==null?void 0:A.origin_country)||[]).includes("JP")||(A==null?void 0:A.original_language)==="ja"),b=[];d!=null&&d.name&&b.push(d.name);let y=(A==null?void 0:A.title)||(A==null?void 0:A.name)||"",x=(A==null?void 0:A.original_title)||(A==null?void 0:A.original_name)||"";y&&b.push(y),x&&x!==y&&b.push(x);let S=(d==null?void 0:d.air_date)||(A==null?void 0:A.release_date)||(A==null?void 0:A.first_air_date)||"",U={isAnime:I,titles:[...new Set(b.filter(Boolean))],year:S?parseInt(S.slice(0,4),10):null,imdbId:(A==null?void 0:A.imdb_id)||((p=A==null?void 0:A.external_ids)==null?void 0:p.imdb_id)||null};return Ci(je,a,U,6e5),U}catch{return{isAnime:!1,titles:[],year:null,imdbId:null}}}var Ne,j,v,je,vi,Ui,T=F(()=>{$();Le();Ne=["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15","Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0","Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0"],j=()=>Ne[Math.floor(Math.random()*Ne.length)],v=Ne[0];je=new Map,vi=new Map,Ui=new Map;setInterval(()=>{Oe(Ui),Oe(je),Oe(vi)},6e4)});var vt={};Q(vt,{getStream:()=>_i});async function ki(r,t,e,n){let i=(n==null?void 0:n.titles)||[],s=[];for(let l of i){let a=l.toLowerCase().replace(/[^a-z0-9\s]/g," ").replace(/\s+/g," ").trim();s.includes(a)||s.push(a);let c=l.split(/[\s:]+/).filter(f=>f.length>2);if(c.length>=2){let f=c.slice(0,3).join(" ").toLowerCase();s.includes(f)||s.push(f)}}let o=[];for(let l of s){try{let c=(await k(`https://123animehub.cc/ajax/film/search?keyword=${encodeURIComponent(l)}&_=${Date.now()}`,{headers:{"X-Requested-With":"XMLHttpRequest",Referer:"https://123animehub.cc",Accept:"application/json","User-Agent":v},signal:AbortSignal.timeout(8e3)})).html||"",f=0;for(;(f=c.indexOf('href="/anime/',f))!==-1;){let u=f+13,p=c.indexOf('"',u),m=c.slice(u,p);o.includes(m)||o.push(m),f=p}}catch{}if(o.length)break}for(let l of o)try{let a=await k(`https://123animehub.cc/ajax/episode/info?epr=${encodeURIComponent(t?`${l}/${t}/${e}`:`${l}/1`)}&ts=1&_=${Date.now()}`,{headers:{Referer:`https://123animehub.cc/anime/${l}`,"X-Requested-With":"XMLHttpRequest",Accept:"application/json, text/javascript, */*; q=0.01","User-Agent":v},signal:AbortSignal.timeout(1e4)});if(a!=null&&a.target)return a.target}catch{}return null}async function _i({id:r,s:t,e}){if(!t)return null;let n=await K(r,"tv",t);if(!n.isAnime)return null;let i=await ki(r,t,e,n);if(!i)return null;let s=i.match(/\/embed-[^/]+\/([A-Za-z0-9+/=]+)$/);if(!s)return null;let o=new URL(i).origin;try{let l=await fetch(i,{headers:{"User-Agent":v},signal:AbortSignal.timeout(1e4)});if(!l.ok)return null;let a=l.headers.get("set-cookie"),c=await k(`${o}/hs/getSources?id=${s[1]}`,{headers:{Referer:i,Accept:"*/*","User-Agent":v,...a?{Cookie:a.split(";")[0].trim()}:{}},signal:AbortSignal.timeout(1e4)}),f=typeof c.sources=="string"&&c.sources.length?c.sources:Array.isArray(c.sources)&&c.sources.length?c.sources[0].file||c.sources[0].src||c.sources[0].url:null;if(f)return{url:f,headers:{Referer:"https://play2.echovideo.ru/",Origin:"https://play2.echovideo.ru"}}}catch{}return null}var De=F(()=>{$();T()});var Ut={};Q(Ut,{getStream:()=>Mi});function Ti(r){let t="https://a.111477.xyz/::sort=file-desc::limit=3";r&&(t+=`::tmdb=${r}`);let e=R.Buffer.from(t,"utf8").toString("base64").replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");return`${Fi}/config/${e}`}async function Qi(r,t,e){var s;let n=(r==null?void 0:r.tmdbApiKey)||process.env.TMDB_API_KEY;if(!n)return null;let i=e?"tv":"movie";try{let o=await fetch(`https://api.themoviedb.org/3/${i}/${t}?api_key=${n}&append_to_response=external_ids`,{signal:AbortSignal.timeout(6e3)});if(!o.ok)return null;let l=await o.json();return l.imdb_id||((s=l.external_ids)==null?void 0:s.imdb_id)||null}catch{return null}}async function Mi({id:r,s:t,e,sdk:n}){let i=!!(t&&e),s=j(),o=(n==null?void 0:n.tmdbApiKey)||process.env.TMDB_API_KEY,l=await Qi(n,r,i),a=[];l&&l.startsWith("tt")&&a.push(l),a.push(`tmdb:${r}`);let c=Ti(o),f=new Set,u=[],p={"User-Agent":s,Accept:"application/json, text/plain, */*"};for(let m of a){let h=i?`${c}/stream/series/${m}:${t||1}:${e||1}.json`:`${c}/stream/movie/${m}.json`;try{let A=await fetch(h,{headers:p,signal:AbortSignal.timeout(12e3)});if(!A.ok)continue;let d=await A.json();if(Array.isArray(d==null?void 0:d.streams)&&d.streams.length>0){for(let w of d.streams){if(!w||typeof w!="object")continue;let I=String(w.url||"").trim();if(!I||!I.startsWith("http")||f.has(I))continue;f.add(I);let b=w.title?String(w.title).trim():"",y=w.name?String(w.name).trim():"111477",x=b||y;u.push({url:I,label:x,headers:p})}if(u.length>0)break}}catch{}}return u.length===0?null:{url:u[0].url,allUrls:u,headers:u[0].headers}}var Fi,He=F(()=>{$();T();Fi="https://st.111477.xyz"});var Rt={};Q(Rt,{getStream:()=>Oi});async function Pi(r){try{let t=await fetch(r,{headers:{"User-Agent":v,Referer:"https://anihq.cc/"}}),e=await t.text(),n=e.match(/window\.location\.href\s*=\s*['"](https?:\/\/[^'"]+)['"]/);n&&(t=await fetch(n[1],{headers:{"User-Agent":v,Referer:"https://anihq.cc/"}}),e=await t.text());let i=t.url,s=e.match(/ + + + + + + """.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() + 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() + 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() + 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() + 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 { + 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() + 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 = withContext(Dispatchers.IO) { + getSources() + val callId = "sub_${callCounter.incrementAndGet()}" + val deferred = CompletableDeferred>() + 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 = 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) {} + } + } + } + } +} diff --git a/android/app/src/main/java/tv/vyla/app/ui/components/MediaCard.kt b/android/app/src/main/java/tv/vyla/app/ui/components/MediaCard.kt new file mode 100644 index 0000000..820f225 --- /dev/null +++ b/android/app/src/main/java/tv/vyla/app/ui/components/MediaCard.kt @@ -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 + ) + } + } + } +} diff --git a/android/app/src/main/java/tv/vyla/app/ui/components/Sidebar.kt b/android/app/src/main/java/tv/vyla/app/ui/components/Sidebar.kt new file mode 100644 index 0000000..3821051 --- /dev/null +++ b/android/app/src/main/java/tv/vyla/app/ui/components/Sidebar.kt @@ -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(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) + ) + } + } +} diff --git a/android/app/src/main/java/tv/vyla/app/ui/screens/BrowseScreen.kt b/android/app/src/main/java/tv/vyla/app/ui/screens/BrowseScreen.kt new file mode 100644 index 0000000..3f58095 --- /dev/null +++ b/android/app/src/main/java/tv/vyla/app/ui/screens/BrowseScreen.kt @@ -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 +) +@Composable + +fun BrowseScreen( + mode: NavDestination, + onOpenDetail: (MediaItem) -> Unit, + onPlay: (MediaItem) -> Unit, + onRequestSidebar: () -> Unit, + modifier: Modifier = Modifier +) { + val coroutineScope = rememberCoroutineScope() + var heroItem by remember { mutableStateOf(null) } + var heroLogoUrl by remember { mutableStateOf(null) } + var heroJob by remember { mutableStateOf(null) } + val rows = remember { mutableStateListOf() } + var isLoadingMore by remember { mutableStateOf(false) } + var rowIdx by remember { mutableIntStateOf(0) } + val movieGenres = remember { mutableStateListOf() } + val tvGenres = remember { mutableStateListOf() } + 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 + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/tv/vyla/app/ui/screens/DetailScreen.kt b/android/app/src/main/java/tv/vyla/app/ui/screens/DetailScreen.kt new file mode 100644 index 0000000..84584f7 --- /dev/null +++ b/android/app/src/main/java/tv/vyla/app/ui/screens/DetailScreen.kt @@ -0,0 +1,1050 @@ +package tv.vyla.app.ui.screens + +import android.content.Intent + +import android.net.Uri + +import androidx.activity.compose.BackHandler + +import androidx.compose.animation.AnimatedVisibility + +import androidx.compose.animation.core.animateFloatAsState + +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.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.* + +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.draw.drawWithContent + +import androidx.compose.ui.graphics.BlendMode + +import androidx.compose.ui.graphics.CompositingStrategy + +import androidx.compose.ui.graphics.graphicsLayer + +import androidx.compose.ui.graphics.Brush + +import androidx.compose.ui.graphics.Color + +import androidx.compose.ui.layout.ContentScale + +import androidx.compose.ui.platform.LocalContext + +import androidx.compose.ui.text.font.FontWeight + +import androidx.compose.ui.text.style.TextAlign + +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.delay + +import kotlinx.coroutines.launch + +import tv.vyla.app.api.TmdbApi + +import tv.vyla.app.model.* + +import tv.vyla.app.ui.components.MediaCard + +import tv.vyla.app.ui.theme.* +@Composable + +fun DetailScreen( + item: MediaItem, + isWatchLater: Boolean, + isFavorite: Boolean, + onToggleWatchLater: (MediaItem) -> Unit, + onToggleFavorite: (MediaItem) -> Unit, + onPlay: (MediaItem, Episode?) -> Unit, + onOpenRelated: (MediaItem) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val listState = rememberLazyListState() + var detail by remember { mutableStateOf(null) } + var selectedSeason by remember { mutableStateOf(null) } + var seasonEpisodes by remember { mutableStateOf>(emptyList()) } + var isOverviewExpanded by remember { mutableStateOf(false) } + var isMenuOpen by remember { mutableStateOf(false) } + var isSeasonDropdownOpen by remember { mutableStateOf(false) } + val playButtonFocusRequester = remember { FocusRequester() } + LaunchedEffect(item) { + listState.scrollToItem(0) + val d = TmdbApi.getDetail(item.mediaType, item.id) + detail = d + if (d != null && d.seasons.isNotEmpty()) { + val firstSeason = d.seasons.first() + selectedSeason = firstSeason + seasonEpisodes = TmdbApi.getSeasonEpisodes(item.id, firstSeason.seasonNumber) + } + delay(150) + try { + playButtonFocusRequester.requestFocus() + } catch (_: Exception) {} + } + LaunchedEffect(selectedSeason) { + val s = selectedSeason + if (s != null) { + seasonEpisodes = TmdbApi.getSeasonEpisodes(item.id, s.seasonNumber) + } + } + BackHandler { + if (isSeasonDropdownOpen) { + isSeasonDropdownOpen = false + } else if (isMenuOpen) { + isMenuOpen = false + } else { + onBack() + } + } + val d = detail + val hasEpisodes = item.mediaType == "tv" && d != null && d.seasons.isNotEmpty() + val hasTrailers = d != null && d.trailers.isNotEmpty() + val hasRelated = d != null && d.recommendations.isNotEmpty() + val hasCast = d != null && d.cast.isNotEmpty() + val episodesItemIndex = 1 + val trailersItemIndex = 1 + (if (hasEpisodes) 1 else 0) + val relatedItemIndex = trailersItemIndex + (if (hasTrailers) 1 else 0) + val castItemIndex = relatedItemIndex + (if (hasRelated) 1 else 0) + fun scrollToTop() { + coroutineScope.launch { + listState.animateScrollToItem(0) + } + } + fun scrollToSection(index: Int) { + coroutineScope.launch { + listState.animateScrollToItem(index, scrollOffset = -80) + } + } + Box(modifier = modifier.fillMaxSize().background(Color(0xFF07070A))) { + val bg = item.ambientBackdropUrl + if (!bg.isNullOrEmpty()) { + AsyncImage( + model = bg, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(60.dp) + ) + } + val backdrop = item.backdropUrl + if (!backdrop.isNullOrEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.82f) + ) { + AsyncImage( + model = backdrop, + 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(0x99000000), + 0.50f to Color(0x33000000), + 1.0f to Color.Transparent + ) + ) + ) + DetailBackButton( + onClick = onBack, + onFocused = { scrollToTop() }, + modifier = Modifier + .padding(start = 36.dp, top = 26.dp) + .align(Alignment.TopStart) + .zIndex(15f) + ) + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 96.dp, bottom = 64.dp) + ) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 48.dp) + .widthIn(max = 860.dp) + ) { + if (!d?.logoUrl.isNullOrEmpty()) { + AsyncImage( + model = d?.logoUrl, + contentDescription = item.displayTitle, + contentScale = ContentScale.Fit, + modifier = Modifier + .height(120.dp) + .widthIn(max = 480.dp) + .padding(bottom = 16.dp) + ) + } else { + Text( + text = item.displayTitle, + color = Color(0xFFF2F2F5), + fontSize = 42.sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-1).sp, + lineHeight = 48.sp, + modifier = Modifier.padding(bottom = 14.dp) + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(bottom = 16.dp) + ) { + Text( + text = "${item.formattedRating}", + color = Color(0xFFFFD700), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = item.year, + color = Color(0xFFD9D9E4), + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + d?.certification?.let { cert -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(Color(0x33FFFFFF)) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = cert, + color = Color.White, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + d?.runtimeOrSeasons?.let { rt -> + if (rt.isNotEmpty()) { + Text( + text = rt, + color = Color(0xFFD9D9E4), + fontSize = 15.sp + ) + } + } + if (d != null && d.genres.isNotEmpty()) { + Text( + text = d.genres.take(3).joinToString(", ") { it.name }, + color = Color(0xFFAAAAAA), + fontSize = 14.sp + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(bottom = 18.dp) + ) { + DetailPlayButton( + text = "Play", + focusRequester = playButtonFocusRequester, + onFocused = { scrollToTop() }, + onClick = { + scrollToTop() + val ep = if (item.mediaType == "tv") seasonEpisodes.firstOrNull() else null + onPlay(item, ep) + } + ) + DetailCircleButton( + icon = Icons.Default.MoreVert, + isSelected = isMenuOpen, + label = "Options", + onFocused = { scrollToTop() }, + onClick = { isMenuOpen = !isMenuOpen } + ) + AnimatedVisibility( + visible = isMenuOpen, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(150)) + ) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + DetailCircleButton( + icon = if (isWatchLater) Icons.Default.Check else Icons.Default.Schedule, + isSelected = isWatchLater, + label = if (isWatchLater) "Watch Later (Added)" else "Watch Later", + onFocused = { scrollToTop() }, + onClick = { onToggleWatchLater(item) } + ) + DetailCircleButton( + icon = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + isSelected = isFavorite, + label = if (isFavorite) "Favorites (Added)" else "Favorites", + onFocused = { scrollToTop() }, + onClick = { onToggleFavorite(item) } + ) + } + } + } + val overview = d?.item?.overview ?: item.overview + if (!overview.isNullOrEmpty()) { + Text( + text = overview, + color = Color(0xFFD9D9E4), + fontSize = 15.sp, + lineHeight = 23.sp, + maxLines = if (isOverviewExpanded) Int.MAX_VALUE else 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 8.dp) + ) + DetailMoreButton( + isExpanded = isOverviewExpanded, + onFocused = { scrollToTop() }, + onClick = { isOverviewExpanded = !isOverviewExpanded } + ) + } + } + } + if (hasEpisodes) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 32.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(20.dp), + modifier = Modifier.padding(horizontal = 48.dp, vertical = 12.dp) + ) { + Text( + text = "Episodes", + color = Color.White, + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) + selectedSeason?.let { season -> + SeasonDropdownButton( + currentSeason = season, + isOpen = isSeasonDropdownOpen, + onFocused = { scrollToSection(episodesItemIndex) }, + onClick = { isSeasonDropdownOpen = !isSeasonDropdownOpen } + ) + } + } + if (isSeasonDropdownOpen) { + LazyRow( + contentPadding = PaddingValues(horizontal = 48.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .padding(vertical = 10.dp) + .background(Color(0x9916161E)) + .padding(vertical = 8.dp) + ) { + items(d?.seasons ?: emptyList()) { season -> + SeasonSelectChip( + season = season, + isSelected = selectedSeason?.seasonNumber == season.seasonNumber, + onFocused = { scrollToSection(episodesItemIndex) }, + onSelect = { + selectedSeason = season + isSeasonDropdownOpen = false + } + ) + } + } + } + val rowsOfEpisodes = seasonEpisodes.chunked(4) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 48.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + if (seasonEpisodes.isEmpty()) { + Text( + text = "No episodes listed.", + color = Color(0xFFAAAAAA), + fontSize = 15.sp, + modifier = Modifier.padding(vertical = 16.dp) + ) + } else { + rowsOfEpisodes.forEach { rowEpisodes -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(18.dp) + ) { + rowEpisodes.forEach { ep -> + Box(modifier = Modifier.weight(1f)) { + DetailEpisodeCard( + episode = ep, + onFocused = { scrollToSection(episodesItemIndex) }, + onClick = { onPlay(item, ep) } + ) + } + } + repeat(4 - rowEpisodes.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } + } + } + } + if (hasTrailers) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 28.dp) + ) { + Text( + text = "Trailers", + color = Color.White, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 48.dp, vertical = 12.dp) + ) + LazyRow( + contentPadding = PaddingValues(horizontal = 48.dp), + horizontalArrangement = Arrangement.spacedBy(18.dp) + ) { + items(d?.trailers ?: emptyList()) { trailer -> + DetailTrailerCard( + trailer = trailer, + onFocused = { scrollToSection(trailersItemIndex) }, + onClick = { + try { + val ytIntent = Intent( + Intent.ACTION_VIEW, + Uri.parse("https://www.youtube.com/watch?v=${trailer.key}") + ) + context.startActivity(ytIntent) + } catch (_: Exception) {} + } + ) + } + } + } + } + } + if (hasRelated) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 28.dp) + ) { + Text( + text = "Related", + color = Color.White, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 48.dp, vertical = 12.dp) + ) + LazyRow( + contentPadding = PaddingValues(horizontal = 48.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(d?.recommendations ?: emptyList()) { rec -> + MediaCard( + item = rec, + width = 136, + onFocused = { scrollToSection(relatedItemIndex) }, + onSelect = { onOpenRelated(rec) } + ) + } + } + } + } + } + if (hasCast) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 28.dp) + ) { + Text( + text = "Cast", + color = Color.White, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 48.dp, vertical = 12.dp) + ) + LazyRow( + contentPadding = PaddingValues(horizontal = 48.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp) + ) { + items(d?.cast ?: emptyList()) { member -> + DetailCastPortrait( + member = member, + onFocused = { scrollToSection(castItemIndex) }, + onClick = {} + ) + } + } + } + } + } + } + } +} +@Composable +private fun DetailBackButton( + onClick: () -> Unit, + onFocused: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (isFocused) 1.08f else 1.0f, label = "back_scale") + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .scale(scale) + .size(46.dp) + .clip(CircleShape) + .background(if (isFocused) Color.White else Color(0x26FFFFFF)) + .then(if (isFocused) Modifier.border(2.dp, Color.White, CircleShape) else Modifier) + .onFocusChanged { + isFocused = it.isFocused + if (it.isFocused) onFocused?.invoke() + } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = if (isFocused) Color.Black else Color.White, + modifier = Modifier.size(22.dp) + ) + } +} +@Composable +private fun DetailPlayButton( + text: String, + focusRequester: FocusRequester, + onFocused: (() -> Unit)? = null, + onClick: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (isFocused) 1.06f else 1.0f, label = "play_scale") + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .scale(scale) + .height(48.dp) + .clip(RoundedCornerShape(24.dp)) + .background(Color.White) + .then(if (isFocused) Modifier.border(2.5.dp, Color(0xFF007AFF), RoundedCornerShape(24.dp)) else Modifier) + .focusRequester(focusRequester) + .onFocusChanged { + isFocused = it.isFocused + if (it.isFocused) onFocused?.invoke() + } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + .padding(horizontal = 28.dp) + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + tint = Color.Black, + modifier = Modifier.size(22.dp) + ) + Text( + text = text, + color = Color.Black, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } +} +@Composable +private fun DetailCircleButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + isSelected: Boolean = false, + label: String? = null, + onFocused: (() -> Unit)? = null, + onClick: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (isFocused) 1.1f else 1.0f, label = "circ_scale") + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .scale(scale) + .size(48.dp) + .clip(CircleShape) + .background( + when { + isFocused -> Color.White + isSelected -> Color(0x55FFFFFF) + else -> Color(0x24FFFFFF) + } + ) + .then(if (isFocused) Modifier.border(2.dp, Color.White, CircleShape) else Modifier) + .onFocusChanged { + isFocused = it.isFocused + if (it.isFocused) onFocused?.invoke() + } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = if (isFocused) Color.Black else Color.White, + modifier = Modifier.size(22.dp) + ) + } +} +@Composable +private fun DetailMoreButton( + isExpanded: Boolean, + onFocused: (() -> Unit)? = null, + onClick: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (isFocused) 1.08f else 1.0f, label = "more_scale") + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .scale(scale) + .clip(RoundedCornerShape(999.dp)) + .background(if (isFocused) Color.White else Color(0x2EFFFFFF)) + .then(if (isFocused) Modifier.border(1.5.dp, Color.White, RoundedCornerShape(999.dp)) else Modifier) + .onFocusChanged { + isFocused = it.isFocused + if (it.isFocused) onFocused?.invoke() + } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + .padding(horizontal = 14.dp, vertical = 5.dp) + ) { + Text( + text = if (isExpanded) "LESS" else "MORE", + color = if (isFocused) Color.Black else Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp + ) + } +} +@Composable +private fun SeasonDropdownButton( + currentSeason: Season, + isOpen: Boolean, + onFocused: (() -> Unit)? = null, + onClick: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (isFocused) 1.04f else 1.0f, label = "ddb_scale") + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .scale(scale) + .height(42.dp) + .clip(RoundedCornerShape(21.dp)) + .background(if (isFocused) Color.White else Color(0x29FFFFFF)) + .then(if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(21.dp)) else Modifier) + .onFocusChanged { + isFocused = it.isFocused + if (it.isFocused) onFocused?.invoke() + } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + .padding(horizontal = 18.dp) + ) { + Text( + text = currentSeason.displayName, + color = if (isFocused) Color.Black else Color.White, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ) + Icon( + imageVector = if (isOpen) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = if (isFocused) Color.Black else Color.White, + modifier = Modifier.size(18.dp) + ) + } +} +@Composable +private fun SeasonSelectChip( + season: Season, + isSelected: Boolean, + onFocused: (() -> Unit)? = null, + onSelect: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background( + when { + isFocused -> Color.White + isSelected -> Color(0x44FFFFFF) + else -> Color(0x18FFFFFF) + } + ) + .then(if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(12.dp)) else Modifier) + .onFocusChanged { + isFocused = it.isFocused + if (it.isFocused) onFocused?.invoke() + } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onSelect() } + .padding(horizontal = 16.dp, vertical = 10.dp) + ) { + Text( + text = season.displayName, + color = if (isFocused) Color.Black else Color.White, + fontSize = 14.sp, + fontWeight = if (isSelected || isFocused) FontWeight.Bold else FontWeight.Medium + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = "${season.episodeCount} eps", + color = if (isFocused) Color(0x88000000) else Color(0x88FFFFFF), + fontSize = 12.sp + ) + } +} +@Composable +private fun DetailEpisodeCard( + episode: Episode, + onFocused: (() -> Unit)? = null, + onClick: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (isFocused) 1.05f else 1.0f, label = "ep_scale") + Column( + modifier = Modifier + .fillMaxWidth() + .scale(scale) + .onFocusChanged { + isFocused = it.isFocused + if (it.isFocused) onFocused?.invoke() + } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFF101016)) + .then(if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(10.dp)) else Modifier) + ) { + val img = episode.stillUrl + if (!img.isNullOrEmpty()) { + AsyncImage( + model = img, + contentDescription = episode.displayTitle, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + tint = Color(0x66FFFFFF), + modifier = Modifier.size(36.dp) + ) + } + } + Box( + modifier = Modifier + .padding(8.dp) + .clip(RoundedCornerShape(999.dp)) + .background(Color(0x99000000)) + .padding(horizontal = 8.dp, vertical = 3.dp) + .align(Alignment.TopStart) + ) { + Text( + text = "E${episode.episodeNumber}", + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + } + } + Text( + text = episode.displayTitle, + color = if (isFocused) Color.White else Color(0xFFF2F2F5), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 10.dp) + ) + val metaParts = listOfNotNull( + "Episode ${episode.episodeNumber}", + episode.runtime?.let { "${it}m" } + ) + Text( + text = metaParts.joinToString(" • "), + color = Color(0x88FFFFFF), + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 3.dp) + ) + } +} +@Composable +private fun DetailTrailerCard( + trailer: VideoClip, + onFocused: (() -> Unit)? = null, + onClick: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (isFocused) 1.05f else 1.0f, label = "tr_scale") + Column( + modifier = Modifier + .width(280.dp) + .scale(scale) + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xFF101016)) + .then(if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(16.dp)) else Modifier) + .onFocusChanged { + isFocused = it.isFocused + if (it.isFocused) onFocused?.invoke() + } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + .padding(bottom = 14.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) + ) { + AsyncImage( + model = trailer.thumbnailUrl, + contentDescription = trailer.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(Color(0x88000000)) + .align(Alignment.Center), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(26.dp) + ) + } + } + Text( + text = trailer.name, + color = Color(0xFFF2F2F5), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 14.dp, end = 14.dp, top = 10.dp) + ) + Text( + text = "▶ ${trailer.type}", + color = Color(0xFFD0D0DA), + fontSize = 12.sp, + modifier = Modifier.padding(start = 14.dp, end = 14.dp, top = 4.dp) + ) + } +} +@Composable +private fun DetailCastPortrait( + member: CastMember, + onFocused: (() -> Unit)? = null, + onClick: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (isFocused) 1.08f else 1.0f, label = "cast_scale") + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .width(110.dp) + .scale(scale) + .onFocusChanged { + isFocused = it.isFocused + if (it.isFocused) onFocused?.invoke() + } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + ) { + Box( + modifier = Modifier + .size(90.dp) + .clip(CircleShape) + .background(Color(0xFF2A2A3C)) + .then(if (isFocused) Modifier.border(2.5.dp, Color.White, CircleShape) else Modifier), + contentAlignment = Alignment.Center + ) { + val photo = member.profileUrl + if (!photo.isNullOrEmpty()) { + AsyncImage( + model = photo, + contentDescription = member.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } else { + Text( + text = member.name.take(1), + color = Color.White, + fontSize = 28.sp, + fontWeight = FontWeight.Bold + ) + } + } + Text( + text = member.name, + color = if (isFocused) Color.White else Color(0xFFF2F2F5), + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp) + ) + member.character?.let { char -> + Text( + text = char, + color = Color(0xFFAAAAAA), + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 2.dp) + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/tv/vyla/app/ui/screens/LibraryScreen.kt b/android/app/src/main/java/tv/vyla/app/ui/screens/LibraryScreen.kt new file mode 100644 index 0000000..b7e5bc7 --- /dev/null +++ b/android/app/src/main/java/tv/vyla/app/ui/screens/LibraryScreen.kt @@ -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, + 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) } + ) + } + } + } + } +} diff --git a/android/app/src/main/java/tv/vyla/app/ui/screens/PlayerScreen.kt b/android/app/src/main/java/tv/vyla/app/ui/screens/PlayerScreen.kt new file mode 100644 index 0000000..3b11c67 --- /dev/null +++ b/android/app/src/main/java/tv/vyla/app/ui/screens/PlayerScreen.kt @@ -0,0 +1,1626 @@ +package tv.vyla.app.ui.screens + +import android.content.Context + +import android.media.AudioManager + +import android.view.KeyEvent + +import android.view.ViewGroup + +import android.widget.FrameLayout + +import androidx.activity.compose.BackHandler + +import androidx.annotation.OptIn + +import androidx.compose.animation.AnimatedVisibility + +import androidx.compose.animation.core.animateDpAsState + +import androidx.compose.animation.core.animateFloatAsState + +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.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.shape.CircleShape + +import androidx.compose.foundation.shape.RoundedCornerShape + +import androidx.compose.material.icons.Icons + +import androidx.compose.material.icons.automirrored.filled.ArrowBack + +import androidx.compose.material.icons.automirrored.filled.VolumeMute + +import androidx.compose.material.icons.automirrored.filled.VolumeUp + +import androidx.compose.material.icons.filled.* + +import androidx.compose.material3.CircularProgressIndicator + +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.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.platform.LocalContext + +import androidx.compose.ui.text.font.FontWeight + +import androidx.compose.ui.text.style.TextAlign + +import androidx.compose.ui.text.style.TextOverflow + +import androidx.compose.ui.unit.dp + +import androidx.compose.ui.unit.sp + +import androidx.compose.ui.viewinterop.AndroidView + +import androidx.compose.ui.zIndex + +import androidx.media3.common.C + +import androidx.media3.common.MediaItem as ExoMediaItem + +import androidx.media3.common.MimeTypes + +import androidx.media3.common.PlaybackParameters + +import androidx.media3.common.Player + +import androidx.media3.common.TrackSelectionParameters + +import androidx.media3.common.util.UnstableApi + +import androidx.media3.exoplayer.ExoPlayer + +import androidx.media3.ui.AspectRatioFrameLayout + +import androidx.media3.ui.PlayerView + +import coil.compose.AsyncImage + +import kotlinx.coroutines.Job + +import kotlinx.coroutines.delay + +import kotlinx.coroutines.launch + +import tv.vyla.app.api.IntroDbApi + +import tv.vyla.app.api.TmdbApi + +import tv.vyla.app.data.LibraryManager + +import tv.vyla.app.model.* + +import tv.vyla.app.proxy.LocalProxy + +import tv.vyla.app.source.VylaResolver + +import tv.vyla.app.ui.theme.* + +enum class ActivePlayerMenu { + NONE, QUALITY, SUBTITLES, VOLUME, SETTINGS, MORE +} +@OptIn(UnstableApi::class) +@Composable + +fun PlayerScreen( + item: MediaItem, + initialEpisode: Episode? = null, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val resolver = remember { VylaResolver.getInstance(context) } + val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager } + val libraryManager = remember { LibraryManager.getInstance(context) } + var currentEpisode by remember { mutableStateOf(initialEpisode) } + var allEpisodes by remember { mutableStateOf>(emptyList()) } + var seasons by remember { mutableStateOf>(emptyList()) } + var selectedSeasonNum by remember { mutableIntStateOf(initialEpisode?.seasonNumber ?: 1) } + val resolvedStreams = remember { mutableStateListOf() } + var activeStream by remember { mutableStateOf(null) } + var activeStreamUrl by remember { mutableStateOf(null) } + var isBuffering by remember { mutableStateOf(true) } + var isPlaying by remember { mutableStateOf(false) } + var currentPositionMs by remember { mutableLongStateOf(0L) } + var durationMs by remember { mutableLongStateOf(0L) } + var bufferedPositionMs by remember { mutableLongStateOf(0L) } + val segments = remember { mutableStateListOf() } + var activeSegment by remember { mutableStateOf(null) } + var areControlsVisible by remember { mutableStateOf(true) } + var activeMenu by remember { mutableStateOf(ActivePlayerMenu.NONE) } + var isEpisodesDrawerOpen by remember { mutableStateOf(false) } + var isFillScreen by remember { mutableStateOf(false) } + var playbackSpeed by remember { mutableFloatStateOf(1.0f) } + val subtitleTracks = remember { mutableStateListOf() } + var selectedSubIndex by remember { mutableIntStateOf(-1) } + var subtitleSizeSp by remember { mutableIntStateOf(24) } + var subtitleColorHex by remember { mutableStateOf("#FFFFFF") } + val playPauseFocusRequester = remember { FocusRequester() } + val seekbarFocusRequester = remember { FocusRequester() } + val menuFirstItemFocusRequester = remember { FocusRequester() } + val episodesButtonFocusRequester = remember { FocusRequester() } + val qualityButtonFocusRequester = remember { FocusRequester() } + val ccButtonFocusRequester = remember { FocusRequester() } + val volButtonFocusRequester = remember { FocusRequester() } + val setButtonFocusRequester = remember { FocusRequester() } + val moreButtonFocusRequester = remember { FocusRequester() } + var hideControlsJob by remember { mutableStateOf(null) } + fun resetHideTimer() { + hideControlsJob?.cancel() + if (activeMenu == ActivePlayerMenu.NONE && !isEpisodesDrawerOpen && isPlaying) { + hideControlsJob = coroutineScope.launch { + delay(3800) + if (isPlaying && activeMenu == ActivePlayerMenu.NONE && !isEpisodesDrawerOpen) { + areControlsVisible = false + } + } + } + } + fun showControlsAndFocusPlay() { + areControlsVisible = true + resetHideTimer() + coroutineScope.launch { + delay(50) + try { + playPauseFocusRequester.requestFocus() + } catch (_: Exception) {} + } + } + val exoPlayer = remember { + ExoPlayer.Builder(context).build().apply { + playWhenReady = true + addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + isBuffering = playbackState == Player.STATE_BUFFERING + if (playbackState == Player.STATE_READY) { + durationMs = duration.coerceAtLeast(0L) + } + } + override fun onIsPlayingChanged(playing: Boolean) { + isPlaying = playing + if (playing) { + resetHideTimer() + } + } + }) + } + } + LaunchedEffect(exoPlayer) { + while (true) { + currentPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L) + bufferedPositionMs = exoPlayer.bufferedPosition.coerceAtLeast(0L) + val dur = exoPlayer.duration + if (dur > 0L) durationMs = dur + val currentSec = currentPositionMs / 1000.0 + val seg = segments.firstOrNull { s -> + val afterStart = currentSec >= s.start + val beforeEnd = s.end == null || currentSec <= s.end + afterStart && beforeEnd + } + activeSegment = seg + delay(300) + } + } + DisposableEffect(Unit) { + onDispose { + hideControlsJob?.cancel() + exoPlayer.stop() + exoPlayer.release() + } + } + fun playStream(url: String, headers: Map = emptyMap()) { + isBuffering = true + activeStreamUrl = url + val proxyUrl = LocalProxy.buildProxyUrl(url, headers) + val mediaItem = ExoMediaItem.Builder() + .setUri(proxyUrl) + .apply { + if (url.contains(".m3u8")) { + setMimeType(MimeTypes.APPLICATION_M3U8) + } + } + .build() + exoPlayer.setMediaItem(mediaItem) + exoPlayer.prepare() + exoPlayer.play() + resetHideTimer() + } + fun loadSeasonEpisodes(seasonNum: Int) { + selectedSeasonNum = seasonNum + coroutineScope.launch { + val eps = TmdbApi.getSeasonEpisodes(item.id, seasonNum) + allEpisodes = eps + } + } + LaunchedEffect(item) { + if (item.mediaType == "tv") { + val d = TmdbApi.getDetail("tv", item.id) + if (d != null) { + seasons = d.seasons + val firstSeasonNum = initialEpisode?.seasonNumber ?: seasons.firstOrNull()?.seasonNumber ?: 1 + loadSeasonEpisodes(firstSeasonNum) + } + } + } + LaunchedEffect(item, currentEpisode) { + resolvedStreams.clear() + activeStream = null + activeStreamUrl = null + isBuffering = true + segments.clear() + activeSegment = null + val ep = currentEpisode + val s = ep?.seasonNumber + val e = ep?.episodeNumber + coroutineScope.launch { + val segList = IntroDbApi.getTimestamps(item.id, s, e) + segments.clear() + segments.addAll(segList) + } + coroutineScope.launch { + val subs = resolver.getSubtitles(item.id, s, e) + subtitleTracks.clear() + subtitleTracks.addAll(subs) + } + resolver.startConcurrentDiscovery( + tmdbId = item.id, + season = s, + episode = e, + onFirstPlayable = { stream -> + coroutineScope.launch { + activeStream = stream + resolvedStreams.add(stream) + playStream(stream.primaryUrl, stream.headers) + } + }, + onStreamResolved = { stream -> + coroutineScope.launch { + if (!resolvedStreams.any { it.provider == stream.provider }) { + resolvedStreams.add(stream) + } + } + } + ) + } + LaunchedEffect(Unit) { + showControlsAndFocusPlay() + } + BackHandler { + when { + activeMenu != ActivePlayerMenu.NONE -> { + val closedMenu = activeMenu + activeMenu = ActivePlayerMenu.NONE + areControlsVisible = true + resetHideTimer() + coroutineScope.launch { + delay(50) + try { + when (closedMenu) { + ActivePlayerMenu.QUALITY -> qualityButtonFocusRequester.requestFocus() + ActivePlayerMenu.SUBTITLES -> ccButtonFocusRequester.requestFocus() + ActivePlayerMenu.VOLUME -> volButtonFocusRequester.requestFocus() + ActivePlayerMenu.SETTINGS -> setButtonFocusRequester.requestFocus() + ActivePlayerMenu.MORE -> moreButtonFocusRequester.requestFocus() + else -> playPauseFocusRequester.requestFocus() + } + } catch (_: Exception) {} + } + } + isEpisodesDrawerOpen -> { + isEpisodesDrawerOpen = false + areControlsVisible = true + resetHideTimer() + coroutineScope.launch { + delay(50) + try { + episodesButtonFocusRequester.requestFocus() + } catch (_: Exception) {} + } + } + areControlsVisible -> { + areControlsVisible = false + } + else -> onBack() + } + } + val isNextEpAvailable = remember(currentEpisode, allEpisodes, seasons) { + val ep = currentEpisode + if (ep == null) false + else { + val idx = allEpisodes.indexOfFirst { it.episodeNumber == ep.episodeNumber } + idx >= 0 && (idx + 1 < allEpisodes.size || seasons.any { it.seasonNumber > ep.seasonNumber }) + } + } + fun playNextEpisode() { + val ep = currentEpisode ?: return + val idx = allEpisodes.indexOfFirst { it.episodeNumber == ep.episodeNumber } + if (idx >= 0 && idx + 1 < allEpisodes.size) { + currentEpisode = allEpisodes[idx + 1] + } else { + val nextSeason = seasons.firstOrNull { it.seasonNumber > ep.seasonNumber } + if (nextSeason != null) { + coroutineScope.launch { + val nextEps = TmdbApi.getSeasonEpisodes(item.id, nextSeason.seasonNumber) + allEpisodes = nextEps + selectedSeasonNum = nextSeason.seasonNumber + currentEpisode = nextEps.firstOrNull() + } + } + } + } + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black) + .focusable() + .onKeyEvent { event -> + if (event.type == KeyEventType.KeyDown) { + val code = event.nativeKeyEvent.keyCode + + // If controls are hidden, show them on any key press + if (!areControlsVisible) { + if (code == KeyEvent.KEYCODE_DPAD_CENTER) { + if (exoPlayer.isPlaying) exoPlayer.pause() else exoPlayer.play() + } + showControlsAndFocusPlay() + return@onKeyEvent true + } + + // Controls are visible - only handle media keys, let D-pad events pass through to buttons + when (code) { + KeyEvent.KEYCODE_MEDIA_PLAY -> { + exoPlayer.play() + showControlsAndFocusPlay() + return@onKeyEvent true + } + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + exoPlayer.pause() + showControlsAndFocusPlay() + return@onKeyEvent true + } + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { + if (exoPlayer.isPlaying) exoPlayer.pause() else exoPlayer.play() + showControlsAndFocusPlay() + return@onKeyEvent true + } + KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { + exoPlayer.seekTo((exoPlayer.currentPosition + 10000).coerceAtMost(exoPlayer.duration)) + resetHideTimer() + return@onKeyEvent true + } + KeyEvent.KEYCODE_MEDIA_REWIND -> { + exoPlayer.seekTo((exoPlayer.currentPosition - 10000).coerceAtLeast(0)) + resetHideTimer() + return@onKeyEvent true + } + KeyEvent.KEYCODE_SPACE -> { + if (exoPlayer.isPlaying) exoPlayer.pause() else exoPlayer.play() + showControlsAndFocusPlay() + return@onKeyEvent true + } + KeyEvent.KEYCODE_BACK -> { + resetHideTimer() + return@onKeyEvent false // Let BackHandler handle it + } + KeyEvent.KEYCODE_DPAD_CENTER -> { + resetHideTimer() + return@onKeyEvent false // Let the focused button handle it + } + } + resetHideTimer() + } + false + } + ) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + player = exoPlayer + useController = false + isFocusable = false + isFocusableInTouchMode = false + descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS + resizeMode = if (isFillScreen) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + }, + update = { view -> + view.resizeMode = if (isFillScreen) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT + }, + modifier = Modifier.fillMaxSize() + ) + if (isBuffering) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = Color.White, strokeWidth = 3.dp, modifier = Modifier.size(54.dp)) + } + } + val seg = activeSegment + val showNextPrompt = isNextEpAvailable && durationMs > 0 && (durationMs - currentPositionMs) in 1..300000 + if ((seg != null || showNextPrompt) && activeMenu == ActivePlayerMenu.NONE && !isEpisodesDrawerOpen) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 40.dp, bottom = 110.dp) + .zIndex(10f) + ) { + if (seg != null) { + PlayerPillButton( + text = seg.label, + icon = Icons.Default.FastForward, + onClick = { + val targetMs = ((seg.end ?: ((currentPositionMs / 1000.0) + 30.0)) + 0.5) * 1000.0 + exoPlayer.seekTo(targetMs.toLong().coerceAtMost(durationMs)) + activeSegment = null + resetHideTimer() + } + ) + } + if (showNextPrompt) { + PlayerPillButton( + text = "Next Episode", + icon = Icons.Default.SkipNext, + isPrimary = true, + onClick = { + playNextEpisode() + resetHideTimer() + } + ) + } + } + } + AnimatedVisibility( + visible = areControlsVisible && !isEpisodesDrawerOpen, + enter = fadeIn(tween(250)), + exit = fadeOut(tween(250)), + modifier = Modifier.fillMaxSize() + ) { + Box(modifier = Modifier.fillMaxSize()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .background( + Brush.verticalGradient( + listOf(Color(0xCC000000), Color(0x66000000), Color.Transparent) + ) + ) + .padding(horizontal = 36.dp, vertical = 24.dp) + ) { + PlayerCircleButton( + icon = Icons.Default.Close, + label = "Close Player", + onClick = onBack + ) + Spacer(modifier = Modifier.width(14.dp)) + val posterThumb = item.posterUrl + if (!posterThumb.isNullOrEmpty()) { + AsyncImage( + model = posterThumb, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(46.dp) + .clip(CircleShape) + .background(Color(0xFF2A2A34)) + ) + Spacer(modifier = Modifier.width(14.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.displayTitle, + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + val epSubtitle = currentEpisode?.let { ep -> + "S${ep.seasonNumber} E${ep.episodeNumber} • ${ep.displayTitle}" + } ?: if (item.mediaType == "tv") "TV Series" else item.year + Text( + text = epSubtitle, + color = Color(0xB3FFFFFF), + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp) + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PlayerCircleButton( + icon = Icons.Default.Tune, + label = "Quality", + isSelected = activeMenu == ActivePlayerMenu.QUALITY, + focusRequester = qualityButtonFocusRequester, + onClick = { + activeMenu = if (activeMenu == ActivePlayerMenu.QUALITY) ActivePlayerMenu.NONE else ActivePlayerMenu.QUALITY + } + ) + PlayerCircleButton( + icon = Icons.Default.ClosedCaption, + label = "Subtitles", + isSelected = activeMenu == ActivePlayerMenu.SUBTITLES, + focusRequester = ccButtonFocusRequester, + onClick = { + activeMenu = if (activeMenu == ActivePlayerMenu.SUBTITLES) ActivePlayerMenu.NONE else ActivePlayerMenu.SUBTITLES + } + ) + PlayerCircleButton( + icon = Icons.AutoMirrored.Filled.VolumeUp, + label = "Volume", + isSelected = activeMenu == ActivePlayerMenu.VOLUME, + focusRequester = volButtonFocusRequester, + onClick = { + activeMenu = if (activeMenu == ActivePlayerMenu.VOLUME) ActivePlayerMenu.NONE else ActivePlayerMenu.VOLUME + } + ) + PlayerCircleButton( + icon = Icons.Default.Settings, + label = "Settings", + isSelected = activeMenu == ActivePlayerMenu.SETTINGS, + focusRequester = setButtonFocusRequester, + onClick = { + activeMenu = if (activeMenu == ActivePlayerMenu.SETTINGS) ActivePlayerMenu.NONE else ActivePlayerMenu.SETTINGS + } + ) + PlayerCircleButton( + icon = Icons.Default.MoreHoriz, + label = "More", + isSelected = activeMenu == ActivePlayerMenu.MORE, + focusRequester = moreButtonFocusRequester, + onDownPress = { + try { + episodesButtonFocusRequester.requestFocus() + } catch (_: Exception) {} + }, + onClick = { + activeMenu = if (activeMenu == ActivePlayerMenu.MORE) ActivePlayerMenu.NONE else ActivePlayerMenu.MORE + } + ) + } + } + Box( + modifier = Modifier.align(Alignment.Center) + ) { + PlayerCenterPlayButton( + isPlaying = isPlaying, + focusRequester = playPauseFocusRequester, + onDownPress = { + try { + seekbarFocusRequester.requestFocus() + } catch (_: Exception) {} + }, + onClick = { + if (exoPlayer.isPlaying) exoPlayer.pause() else exoPlayer.play() + resetHideTimer() + } + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .background( + Brush.verticalGradient( + listOf(Color.Transparent, Color(0x66000000), Color(0xCC000000)) + ) + ) + .padding(horizontal = 36.dp, vertical = 26.dp) + ) { + Text( + text = formatTime(currentPositionMs), + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.width(52.dp) + ) + val isTvItem = item.mediaType == "tv" || allEpisodes.isNotEmpty() || seasons.isNotEmpty() + PlayerSeekBar( + currentPositionMs = currentPositionMs, + durationMs = durationMs, + bufferedPositionMs = bufferedPositionMs, + focusRequester = seekbarFocusRequester, + onSeekLeft = { + exoPlayer.seekTo((exoPlayer.currentPosition - 10000).coerceAtLeast(0)) + resetHideTimer() + }, + onSeekRight = { + exoPlayer.seekTo((exoPlayer.currentPosition + 10000).coerceAtMost(durationMs)) + resetHideTimer() + }, + onUpPress = { + try { + playPauseFocusRequester.requestFocus() + } catch (_: Exception) {} + }, + onRequestEpisodesFocus = if (isTvItem) { + { + try { + episodesButtonFocusRequester.requestFocus() + } catch (_: Exception) {} + } + } else null, + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + ) + Text( + text = formatTime(durationMs), + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.End, + modifier = Modifier.width(52.dp) + ) + if (isTvItem) { + Spacer(modifier = Modifier.width(16.dp)) + PlayerEpisodesButton( + focusRequester = episodesButtonFocusRequester, + onClick = { + isEpisodesDrawerOpen = true + }, + onLeftPress = { + try { + seekbarFocusRequester.requestFocus() + } catch (_: Exception) {} + }, + onUpPress = { + try { + moreButtonFocusRequester.requestFocus() + } catch (_: Exception) {} + } + ) + } + } + } + } + if (activeMenu != ActivePlayerMenu.NONE) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 80.dp, end = 36.dp) + .width(340.dp) + .heightIn(max = 520.dp) + .clip(RoundedCornerShape(18.dp)) + .background(Color(0xF0101016)) + .border(1.dp, Color(0x33FFFFFF), RoundedCornerShape(18.dp)) + .padding(14.dp) + .zIndex(25f) + ) { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + when (activeMenu) { + ActivePlayerMenu.QUALITY -> { + item { + MenuSectionHeader("Stream Quality") + } + item { + MenuItemRow( + label = "Auto (Default)", + isSelected = activeStreamUrl == activeStream?.primaryUrl, + focusRequester = menuFirstItemFocusRequester, + onClick = { + activeStream?.let { s -> + playStream(s.primaryUrl, s.headers) + } + activeMenu = ActivePlayerMenu.NONE + } + ) + } + activeStream?.allUrls?.let { urls -> + items(urls) { u -> + MenuItemRow( + label = u.quality.ifEmpty { "Stream" }, + isSelected = activeStreamUrl == u.url, + onClick = { + activeStream?.let { s -> + playStream(u.url, s.headers) + } + activeMenu = ActivePlayerMenu.NONE + } + ) + } + } + } + ActivePlayerMenu.SUBTITLES -> { + item { + MenuSectionHeader("Subtitles") + } + item { + MenuItemRow( + label = "Off", + isSelected = selectedSubIndex == -1, + focusRequester = menuFirstItemFocusRequester, + onClick = { + selectedSubIndex = -1 + exoPlayer.trackSelectionParameters = exoPlayer.trackSelectionParameters + .buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) + .build() + activeMenu = ActivePlayerMenu.NONE + } + ) + } + items(subtitleTracks.mapIndexed { idx, sub -> idx to sub }) { (idx, sub) -> + MenuItemRow( + label = "${sub.label} (${sub.source ?: "mirror"})", + isSelected = selectedSubIndex == idx, + onClick = { + selectedSubIndex = idx + activeMenu = ActivePlayerMenu.NONE + } + ) + } + item { + MenuSectionHeader("Subtitle Size") + } + item { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp) + ) { + listOf(18, 22, 26, 32).forEach { sz -> + MiniSelectButton( + text = "${sz}px", + isSelected = subtitleSizeSp == sz, + onClick = { subtitleSizeSp = sz } + ) + } + } + } + item { + MenuSectionHeader("Subtitle Color") + } + item { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp) + ) { + listOf("White" to "#FFFFFF", "Yellow" to "#FFD700", "Cyan" to "#00FFFF").forEach { (name, hex) -> + MiniSelectButton( + text = name, + isSelected = subtitleColorHex == hex, + onClick = { subtitleColorHex = hex } + ) + } + } + } + } + ActivePlayerMenu.VOLUME -> { + item { + MenuSectionHeader("Volume Control") + } + item { + val currentVol = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + val maxVol = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp) + ) { + Icon( + imageVector = if (currentVol > 0) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeMute, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + Text( + text = "${(currentVol * 100) / maxVol}%", + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + item { + MenuItemRow( + label = "Increase Volume (+)", + onClick = { + audioManager.adjustStreamVolume( + AudioManager.STREAM_MUSIC, + AudioManager.ADJUST_RAISE, + AudioManager.FLAG_SHOW_UI + ) + } + ) + } + item { + MenuItemRow( + label = "Decrease Volume (-)", + onClick = { + audioManager.adjustStreamVolume( + AudioManager.STREAM_MUSIC, + AudioManager.ADJUST_LOWER, + AudioManager.FLAG_SHOW_UI + ) + } + ) + } + } + ActivePlayerMenu.SETTINGS -> { + item { + MenuSectionHeader("Available Sources (${resolvedStreams.size})") + } + items(resolvedStreams) { stream -> + MenuItemRow( + label = stream.label, + isSelected = activeStream?.provider == stream.provider, + onClick = { + activeStream = stream + playStream(stream.primaryUrl, stream.headers) + activeMenu = ActivePlayerMenu.NONE + } + ) + } + item { + MenuSectionHeader("Playback Speed") + } + items(listOf(0.5f, 1.0f, 1.25f, 1.5f, 2.0f)) { speed -> + MenuItemRow( + label = if (speed == 1.0f) "Normal (1.0×)" else "${speed}×", + isSelected = playbackSpeed == speed, + onClick = { + playbackSpeed = speed + exoPlayer.playbackParameters = PlaybackParameters(speed) + activeMenu = ActivePlayerMenu.NONE + } + ) + } + item { + MenuSectionHeader("Aspect Ratio") + } + item { + MenuItemRow( + label = "Fit Screen (Preserve Aspect)", + isSelected = !isFillScreen, + onClick = { + isFillScreen = false + activeMenu = ActivePlayerMenu.NONE + } + ) + } + item { + MenuItemRow( + label = "Fill Screen (Zoom & Crop)", + isSelected = isFillScreen, + onClick = { + isFillScreen = true + activeMenu = ActivePlayerMenu.NONE + } + ) + } + } + ActivePlayerMenu.MORE -> { + item { + MenuSectionHeader("Library") + } + item { + MenuItemRow( + label = "Add to Watch Later", + focusRequester = menuFirstItemFocusRequester, + onClick = { + libraryManager.toggle("wl", item) + activeMenu = ActivePlayerMenu.NONE + } + ) + } + item { + MenuItemRow( + label = "Add to Favorites", + onClick = { + libraryManager.toggle("fav", item) + activeMenu = ActivePlayerMenu.NONE + } + ) + } + } + else -> {} + } + } + } + } + if (isEpisodesDrawerOpen) { + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .background( + Brush.verticalGradient( + listOf( + Color.Transparent, + Color(0xCC000000), + Color(0xF5000000), + Color(0xFF07070A) + ) + ) + ) + .padding(top = 40.dp, bottom = 28.dp) + .zIndex(30f) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 36.dp, vertical = 8.dp) + ) { + Text( + text = "Episodes", + color = Color.White, + fontSize = 22.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = item.displayTitle, + color = Color(0x88FFFFFF), + fontSize = 15.sp + ) + Spacer(modifier = Modifier.weight(1f)) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp) + ) { + items(seasons) { season -> + SeasonSelectChip( + season = season, + isSelected = selectedSeasonNum == season.seasonNumber, + onSelect = { + loadSeasonEpisodes(season.seasonNumber) + } + ) + } + } + PlayerCircleButton( + icon = Icons.Default.Close, + label = "Close Episodes", + onClick = { + isEpisodesDrawerOpen = false + coroutineScope.launch { + delay(50) + try { + episodesButtonFocusRequester.requestFocus() + } catch (_: Exception) {} + } + } + ) + } + LazyRow( + contentPadding = PaddingValues(horizontal = 36.dp), + horizontalArrangement = Arrangement.spacedBy(18.dp), + modifier = Modifier.padding(top = 16.dp) + ) { + items(allEpisodes) { ep -> + val isCur = currentEpisode?.seasonNumber == ep.seasonNumber && + currentEpisode?.episodeNumber == ep.episodeNumber + DrawerEpisodeCard( + episode = ep, + isCurrentlyPlaying = isCur, + onClick = { + currentEpisode = ep + isEpisodesDrawerOpen = false + resetHideTimer() + } + ) + } + } + } + } + } + } +} +@Composable +private fun PlayerCircleButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + isSelected: Boolean = false, + focusRequester: FocusRequester? = null, + onDownPress: (() -> Unit)? = null, + onClick: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (isFocused) 1.1f else 1.0f, label = "btn_scale") + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .scale(scale) + .size(46.dp) + .clip(CircleShape) + .background( + when { + isFocused -> Color.White + isSelected -> Color(0x55FFFFFF) + else -> Color(0x22FFFFFF) + } + ) + .then(if (isFocused) Modifier.border(2.dp, Color.White, CircleShape) else Modifier) + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .onFocusChanged { isFocused = it.isFocused } + .onKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_DOWN && onDownPress != null) { + onDownPress() + return@onKeyEvent true + } + false + } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = if (isFocused) Color.Black else Color.White, + modifier = Modifier.size(22.dp) + ) + } +} +@Composable +private fun PlayerEpisodesButton( + focusRequester: FocusRequester, + onLeftPress: () -> Unit, + onUpPress: () -> Unit, + onClick: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (isFocused) 1.15f else 1.0f, label = "eps_scale") + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .scale(scale) + .size(46.dp) + .clip(CircleShape) + .background(if (isFocused) Color.White else Color(0x2EFFFFFF)) + .then( + if (isFocused) Modifier.border(2.dp, Color(0xFF007AFF), CircleShape) + else Modifier.border(1.dp, Color(0x33FFFFFF), CircleShape) + ) + .focusRequester(focusRequester) + .onFocusChanged { isFocused = it.isFocused } + .onKeyEvent { event -> + if (event.type == KeyEventType.KeyDown) { + when (event.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> { + onLeftPress() + return@onKeyEvent true + } + KeyEvent.KEYCODE_DPAD_UP -> { + onUpPress() + return@onKeyEvent true + } + } + } + false + } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + ) { + Icon( + imageVector = Icons.Default.ViewModule, + contentDescription = "Episodes", + tint = if (isFocused) Color.Black else Color.White, + modifier = Modifier.size(24.dp) + ) + } +} +@Composable +private fun PlayerCenterPlayButton( + isPlaying: Boolean, + focusRequester: FocusRequester, + onDownPress: (() -> Unit)? = null, + onClick: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (isFocused) 1.08f else 1.0f, label = "center_scale") + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .scale(scale) + .size(100.dp) + .clip(CircleShape) + .background(if (isFocused) Color(0x44FFFFFF) else Color(0x18FFFFFF)) + .then(if (isFocused) Modifier.border(2.5.dp, Color.White, CircleShape) else Modifier) + .focusRequester(focusRequester) + .onFocusChanged { isFocused = it.isFocused } + .onKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_DOWN && onDownPress != null) { + onDownPress() + return@onKeyEvent true + } + false + } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + ) { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) "Pause" else "Play", + tint = Color.White, + modifier = Modifier.size(60.dp) + ) + } +} +@Composable +private fun PlayerPillButton( + text: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + isPrimary: Boolean = false, + onClick: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (isFocused) 1.06f else 1.0f, label = "pill_scale") + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .scale(scale) + .height(44.dp) + .clip(RoundedCornerShape(22.dp)) + .background( + when { + isFocused -> Color.White + isPrimary -> Color(0xE6FFFFFF) + else -> Color(0x33FFFFFF) + } + ) + .then(if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(22.dp)) else Modifier) + .onFocusChanged { isFocused = it.isFocused } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + .padding(horizontal = 22.dp) + ) { + Text( + text = text, + color = if (isFocused || isPrimary) Color.Black else Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + Icon( + imageVector = icon, + contentDescription = null, + tint = if (isFocused || isPrimary) Color.Black else Color.White, + modifier = Modifier.size(18.dp) + ) + } +} +@Composable +private fun PlayerSeekBar( + currentPositionMs: Long, + durationMs: Long, + bufferedPositionMs: Long, + focusRequester: FocusRequester, + onSeekLeft: () -> Unit, + onSeekRight: () -> Unit, + onUpPress: () -> Unit, + onRequestEpisodesFocus: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + var isFocused by remember { mutableStateOf(false) } + var isScrubbingMode by remember { mutableStateOf(false) } + LaunchedEffect(isFocused) { + if (!isFocused) isScrubbingMode = false + } + val barHeight by animateDpAsState(if (isFocused || isScrubbingMode) 6.dp else 4.dp, label = "bar_height") + val thumbScale by animateFloatAsState(if (isScrubbingMode) 1.5f else if (isFocused) 1.3f else 1.0f, label = "thumb_scale") + val progressFraction = if (durationMs > 0) (currentPositionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f + val bufferFraction = if (durationMs > 0) (bufferedPositionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f + Box( + contentAlignment = Alignment.CenterStart, + modifier = modifier + .height(44.dp) + .focusRequester(focusRequester) + .onFocusChanged { isFocused = it.isFocused } + .onKeyEvent { event -> + if (event.type == KeyEventType.KeyDown) { + when (event.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> { + onUpPress() + return@onKeyEvent true + } + KeyEvent.KEYCODE_DPAD_DOWN -> { + if (onRequestEpisodesFocus != null) { + onRequestEpisodesFocus() + return@onKeyEvent true + } + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (isScrubbingMode) { + onSeekRight() + return@onKeyEvent true + } else { + if (onRequestEpisodesFocus != null) { + onRequestEpisodesFocus() + return@onKeyEvent true + } else { + onSeekRight() + return@onKeyEvent true + } + } + } + KeyEvent.KEYCODE_DPAD_LEFT -> { + onSeekLeft() + return@onKeyEvent true + } + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> { + isScrubbingMode = !isScrubbingMode + return@onKeyEvent true + } + } + } + false + } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { isScrubbingMode = !isScrubbingMode } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(barHeight) + .clip(RoundedCornerShape(3.dp)) + .background(Color(0x33FFFFFF)) + ) + Box( + modifier = Modifier + .fillMaxWidth(bufferFraction) + .height(barHeight) + .clip(RoundedCornerShape(3.dp)) + .background(Color(0x66FFFFFF)) + ) + Box( + modifier = Modifier + .fillMaxWidth(progressFraction) + .height(barHeight) + .clip(RoundedCornerShape(3.dp)) + .background(if (isScrubbingMode) Color(0xFF007AFF) else Color.White) + ) + Box( + modifier = Modifier + .fillMaxWidth(progressFraction) + ) { + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .scale(thumbScale) + .size(16.dp) + .clip(CircleShape) + .background(if (isScrubbingMode) Color(0xFF007AFF) else Color.White) + ) + } + if (isScrubbingMode) { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .offset(y = (-20).dp) + .clip(RoundedCornerShape(999.dp)) + .background(Color(0xEE007AFF)) + .padding(horizontal = 10.dp, vertical = 2.dp) + ) { + Text( + text = "◀ Seeking (±10s) • Press OK to exit ▶", + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + } + } + } +} +@Composable +private fun MenuSectionHeader(title: String) { + Text( + text = title.uppercase(), + color = Color(0x88FFFFFF), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 6.dp) + ) +} +@Composable +private fun MenuItemRow( + label: String, + isSelected: Boolean = false, + focusRequester: FocusRequester? = null, + onClick: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + .clip(RoundedCornerShape(10.dp)) + .background(if (isFocused) Color.White else Color.Transparent) + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .onFocusChanged { isFocused = it.isFocused } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + .padding(horizontal = 14.dp) + ) { + Text( + text = label, + color = if (isFocused) Color.Black else Color.White, + fontSize = 14.sp, + fontWeight = if (isSelected || isFocused) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = if (isFocused) Color.Black else Color.White, + modifier = Modifier.size(18.dp) + ) + } + } +} +@Composable +private fun MiniSelectButton( + text: String, + isSelected: Boolean, + onClick: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background( + when { + isFocused -> Color.White + isSelected -> Color(0x66FFFFFF) + else -> Color(0x22FFFFFF) + } + ) + .onFocusChanged { isFocused = it.isFocused } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Text( + text = text, + color = if (isFocused) Color.Black else Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + } +} +@Composable +private fun DrawerEpisodeCard( + episode: Episode, + isCurrentlyPlaying: Boolean, + onClick: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + val scale by animateFloatAsState(if (isFocused) 1.06f else 1.0f, label = "drawer_scale") + Column( + modifier = Modifier + .width(260.dp) + .scale(scale) + .onFocusChanged { isFocused = it.isFocused } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFF101016)) + .then( + if (isFocused) Modifier.border(2.5.dp, Color.White, RoundedCornerShape(10.dp)) + else if (isCurrentlyPlaying) Modifier.border(2.dp, Color(0xFF007AFF), RoundedCornerShape(10.dp)) + else Modifier.border(1.dp, Color(0x22FFFFFF), RoundedCornerShape(10.dp)) + ) + ) { + val img = episode.stillUrl + if (!img.isNullOrEmpty()) { + AsyncImage( + model = img, + contentDescription = episode.displayTitle, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + tint = Color(0x66FFFFFF), + modifier = Modifier.size(36.dp) + ) + } + } + Box( + modifier = Modifier + .padding(8.dp) + .clip(RoundedCornerShape(999.dp)) + .background(Color(0x99000000)) + .padding(horizontal = 8.dp, vertical = 3.dp) + .align(Alignment.TopStart) + ) { + Text( + text = "E${episode.episodeNumber}", + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Bold + ) + } + if (isCurrentlyPlaying) { + Box( + modifier = Modifier + .padding(8.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xCC007AFF)) + .padding(horizontal = 8.dp, vertical = 3.dp) + .align(Alignment.BottomEnd) + ) { + Text( + text = "PLAYING", + color = Color.White, + fontSize = 10.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + Text( + text = episode.displayTitle, + color = if (isFocused) Color.White else Color(0xFFF2F2F5), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 8.dp) + ) + episode.runtime?.let { rt -> + Text( + text = "${rt}m", + color = Color(0x88FFFFFF), + fontSize = 12.sp, + modifier = Modifier.padding(top = 2.dp) + ) + } + } +} +@Composable +private fun SeasonSelectChip( + season: Season, + isSelected: Boolean, + onSelect: () -> Unit +) { + var isFocused by remember { mutableStateOf(false) } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .height(36.dp) + .clip(RoundedCornerShape(18.dp)) + .background( + when { + isFocused -> Color.White + isSelected -> Color(0x44FFFFFF) + else -> Color(0x22FFFFFF) + } + ) + .then(if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(18.dp)) else Modifier) + .onFocusChanged { isFocused = it.isFocused } + .focusable() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onSelect() } + .padding(horizontal = 16.dp) + ) { + Text( + text = season.displayName, + color = if (isFocused) Color.Black else Color.White, + fontSize = 13.sp, + fontWeight = if (isSelected || isFocused) FontWeight.Bold else FontWeight.Medium + ) + } +} +private fun formatTime(ms: Long): String { + val totalSeconds = (ms / 1000).coerceAtLeast(0) + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + return try { + if (hours > 0) { + String.format("%d:%02d:%02d", hours, minutes, seconds) + } else { + String.format("%d:%02d", minutes, seconds) + } + } catch (_: Exception) { + if (hours > 0) "$hours:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" + else "$minutes:${seconds.toString().padStart(2, '0')}" + } +} \ No newline at end of file diff --git a/android/app/src/main/java/tv/vyla/app/ui/screens/SearchScreen.kt b/android/app/src/main/java/tv/vyla/app/ui/screens/SearchScreen.kt new file mode 100644 index 0000000..94c1e1c --- /dev/null +++ b/android/app/src/main/java/tv/vyla/app/ui/screens/SearchScreen.kt @@ -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(null) } + val coroutineScope = rememberCoroutineScope() + var searchJob by remember { mutableStateOf(null) } + val listState = rememberLazyListState() + val trendingMovies = remember { mutableStateListOf() } + val trendingTv = remember { mutableStateListOf() } + val searchResults = remember { mutableStateListOf() } + 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>> = 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 + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/tv/vyla/app/ui/theme/Theme.kt b/android/app/src/main/java/tv/vyla/app/ui/theme/Theme.kt new file mode 100644 index 0000000..495ddc5 --- /dev/null +++ b/android/app/src/main/java/tv/vyla/app/ui/theme/Theme.kt @@ -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 + } + ) +} diff --git a/android/app/src/main/res/drawable-v24/icon.png b/android/app/src/main/res/drawable-v24/icon.png new file mode 100644 index 0000000..c7f677a Binary files /dev/null and b/android/app/src/main/res/drawable-v24/icon.png differ diff --git a/android/app/src/main/res/drawable/icon.png b/android/app/src/main/res/drawable/icon.png new file mode 100644 index 0000000..c7f677a Binary files /dev/null and b/android/app/src/main/res/drawable/icon.png differ diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index be874e5..ab49d9d 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -1,22 +1,14 @@ - - - - - - \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 85a5dda..be570ec 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,3 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. - buildscript { repositories { @@ -8,10 +6,8 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:8.2.1' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22' classpath 'com.google.gms:google-services:4.4.0' - - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files } } diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle deleted file mode 100644 index 9a5fa87..0000000 --- a/android/capacitor.settings.gradle +++ /dev/null @@ -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') diff --git a/android/settings.gradle b/android/settings.gradle index 3b4431d..9d495b3 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,5 +1 @@ -include ':app' -include ':capacitor-cordova-android-plugins' -project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') - -apply from: 'capacitor.settings.gradle' \ No newline at end of file +include ':app' \ No newline at end of file diff --git a/capacitor.config.json b/capacitor.config.json deleted file mode 100644 index f101bef..0000000 --- a/capacitor.config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "appId": "tv.vyla.app", - "appName": "Vyla", - "webDir": "www", - "server": { - "androidScheme": "https", - "cleartext": true - } -} \ No newline at end of file diff --git a/css/base.css b/css/base.css deleted file mode 100644 index 1e25c73..0000000 --- a/css/base.css +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/css/detail.css b/css/detail.css deleted file mode 100644 index 79bb42d..0000000 --- a/css/detail.css +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/css/library.css b/css/library.css deleted file mode 100644 index 10ab6a8..0000000 --- a/css/library.css +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/css/player.css b/css/player.css deleted file mode 100644 index 4b6b378..0000000 --- a/css/player.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/css/search.css b/css/search.css deleted file mode 100644 index c272fa2..0000000 --- a/css/search.css +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/css/sidebar.css b/css/sidebar.css deleted file mode 100644 index 00e6092..0000000 --- a/css/sidebar.css +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/css/skeleton.css b/css/skeleton.css deleted file mode 100644 index 2a88d24..0000000 --- a/css/skeleton.css +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/index.html b/index.html deleted file mode 100644 index aa5f89a..0000000 --- a/index.html +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - Vyla - - - - - - - - - - - - - - -
-
-
-
-
-
-
-
-
-
- -
-

-
-
-
-
-
-
-
-
- - - Search
-
-
-
-
-
-
-

-
-
- -
-
- - -
-
-
- - -
-
-
-
-
-
- - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/js/browse.js b/js/browse.js deleted file mode 100644 index f5be750..0000000 --- a/js/browse.js +++ /dev/null @@ -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 = `

${t}

`; - $('#hmeta').innerHTML = `★ ${(it.vote_average || 0).toFixed(1)}${yr(it)}${it._t === 'movie' ? 'Movie' : 'Series'}${gn(it)}`; - $('#hov').textContent = it.overview || ''; - const l = await logo(it); - if (heroIt === it && l) $('#hlogo').innerHTML = `${t.replace(/` -} - -function card(it, h = true) { - const c = document.createElement('div'); c.className = 'card f'; c.tabIndex = -1; - c.innerHTML = ``; - 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 = `

${g.name} ${mv ? 'Movies' : 'TV Shows'}

${SK.cards(14)}
`; - 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); diff --git a/js/core.js b/js/core.js deleted file mode 100644 index b7918fb..0000000 --- a/js/core.js +++ /dev/null @@ -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 => `${p}`; -const P_WL = '', P_FV = ''; -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(/ { 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(''); -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); diff --git a/js/detail.js b/js/detail.js deleted file mode 100644 index 7ba0a69..0000000 --- a/js/detail.js +++ /dev/null @@ -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 = `
${ic('')}
` + 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 = `
${ic('')}
${(d.vote_average || 0).toFixed(1)}${year}${cert ? `${cert}` : ''}${rt}${(d.genres || []).slice(0, 3).map(g => g.name).join(', ')}
⋮

${d.overview || ''}

MORE
`; - $('#dback')._a = closeDetail; const secs = $('#dsecs'); - const rowOf = (name, kids) => { if (!kids.length) return; const s = document.createElement('section'); s.className = 'ds'; s.innerHTML = `

${name}

`; 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 = '

Episodes

'; - 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 = `${esc(nm(x))}` + ic(''); - [...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', `
${esc(p.name || 'Episode ' + n)}${['Episode ' + n, p.runtime ? p.runtime + 'm' : ''].filter(Boolean).join(' \u2022 ')}`); - 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 = '

No episodes listed.

' - }; - ss.forEach(x => ddl.appendChild(mkf('ddi', `${esc(nm(x))}${x.episode_count} eps`, () => { 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', `
${v.name}▶ ${v.type}`))); - 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', `
${p.profile_path ? '' : p.name[0]}
${p.name}${p.character || ''}`))); - $('#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 } \ No newline at end of file diff --git a/js/focus.js b/js/focus.js deleted file mode 100644 index 2ba76a9..0000000 --- a/js/focus.js +++ /dev/null @@ -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() } \ No newline at end of file diff --git a/js/input.js b/js/input.js deleted file mode 100644 index 7bf47a2..0000000 --- a/js/input.js +++ /dev/null @@ -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; -}; \ No newline at end of file diff --git a/js/library.js b/js/library.js deleted file mode 100644 index 5c9168c..0000000 --- a/js/library.js +++ /dev/null @@ -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]}${a.length} title${a.length === 1 ? '' : 's'}`; - g.innerHTML = ''; - if (!a.length) { g.innerHTML = '

Nothing here yet. Open a title, press the \u22EE button and add it.

'; 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')) } -} diff --git a/js/main.js b/js/main.js deleted file mode 100644 index ef547ec..0000000 --- a/js/main.js +++ /dev/null @@ -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 = '

Could not reach TMDB.

'; return } - await go('discover'); -})(); diff --git a/js/native.js b/js/native.js deleted file mode 100644 index b0aa31a..0000000 --- a/js/native.js +++ /dev/null @@ -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 [] } - } - }; -})(); \ No newline at end of file diff --git a/js/player.js b/js/player.js deleted file mode 100644 index b42a848..0000000 --- a/js/player.js +++ /dev/null @@ -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) => `${p}`; -const SPK = ''; -const PI = { - x: pi(''), - play: pi('', 1), - pause: pi('', 1), - cc: pi(''), - vol: pi(SPK + ''), - mute: pi(SPK + ''), - qlt: pi(''), - set: pi(''), - more: pi(''), - fs: pi(''), - fx: pi(''), - eps: pi(''), - chk: pi('') -}; - -$('#ptop').innerHTML = `
${PI.x}
${PI.qlt}
${PI.cc}
${PI.vol}
${PI.set}
${PI.more}
`; -$('#pbot').innerHTML = `0:00
0:00
${PI.eps}
${PI.fs}
`; -$('#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 = '
Probing providers...
'; - 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 ? `${expandedSources.has(s.provider) ? '▲' : '▼'}` : ''; - - hdr.innerHTML = `${s.label}${countLabel}
${expandIcon}${s.provider === selectedSourceKey ? PI.chk : ''}
`; - 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 = `${u.quality || 'Stream ' + (i + 1)}${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', `${l}${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 = `Timing Offset
`; - 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 = '
'; - 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${esc(c.it.title || c.it.name || '')}`; - 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' : ''), `
${n}${cur ? '' : ''}
${esc(p.name || 'Episode ' + n)}${p.runtime ? p.runtime + 'm' : 'Episode ' + n}`, () => playEp(sn, i))); - }); - const t = car.querySelector('.cur') || car.firstChild; - if (focus && t) setFocus(t); -} - -$('#pep').innerHTML = `

${PI.x}
`; -$('#pnext').innerHTML = 'Next Episode' + pi(''); -$('#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(); -}); \ No newline at end of file diff --git a/js/search.js b/js/search.js deleted file mode 100644 index 2204daa..0000000 --- a/js/search.js +++ /dev/null @@ -1,70 +0,0 @@ -let sqv = '', bgT, stab = 0, SD = { q: '', all: [], mv: [], tv: [] }; -const SVS = ''; -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', `
${esc(x.title || x.name || '')}★ ${(x.vote_average || 0).toFixed(1)}${yr(x)}${x.media_type === 'tv' ? 'Show' : 'Movie'}`, () => 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 = `

${h}

`; sec.lastChild.append(...a.map(tile)); sres.appendChild(sec) }); - if (!n) sres.innerHTML = `

No results${q ? ` for \u201c${esc(q)}\u201d` : ''}

`; - [...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('', '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(() => `
${SK.tiles(9)}
`).join('') -} diff --git a/js/sidebar.js b/js/sidebar.js deleted file mode 100644 index edb0a85..0000000 --- a/js/sidebar.js +++ /dev/null @@ -1,16 +0,0 @@ -const NAV = [ - ['search', 'Search', ic('')], - ['discover', 'Discovery', ic('')], - ['movie', 'Movies', ic('')], - ['tv', 'TV Shows', ic('')], - ['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 + `${n}`; 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' }) } diff --git a/js/skeleton.js b/js/skeleton.js deleted file mode 100644 index 7358c76..0000000 --- a/js/skeleton.js +++ /dev/null @@ -1,13 +0,0 @@ -const SK = { - cards: n => '
'.repeat(n), - tiles: n => '
'.repeat(n), - chips: n => Array.from({ length: n }, (_, i) => `
`).join(''), - pep: n => '
'.repeat(n), - eps: n => '
'.repeat(n), - rows: n => Array.from({ length: n }, () => '
' + SK.cards(14) + '
').join(''), - detail: it => { - const bp = it.backdrop_path || it.poster_path || ''; - return `
` + - `
${'
'.repeat(4)}
${SK.cards(9)}
` - } -}; \ No newline at end of file diff --git a/package.json b/package.json index cf2e5b1..8be2bb2 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,10 @@ "description": "", "type": "module", "scripts": { - "start": "node server.js", "build:web": "node -e \"const fs = require('fs'); fs.rmSync('www', { recursive: true, force: true }); fs.mkdirSync('www'); ['css', 'js', 'assets'].forEach(d => fs.cpSync(d, 'www/' + d, { recursive: true })); fs.copyFileSync('index.html', 'www/index.html');\"", "dev": "node scripts/build-apk.mjs", "release": "node scripts/release.mjs", - "android": "node scripts/build-android.mjs && npx cap sync android" + "android": "node scripts/build-android.mjs" }, "dependencies": { "@capacitor/android": "^6.2.2", @@ -27,4 +26,4 @@ "keywords": [], "author": "", "license": "ISC" -} +} \ No newline at end of file diff --git a/scripts/build-android.mjs b/scripts/build-android.mjs index bab0157..766ac6e 100644 --- a/scripts/build-android.mjs +++ b/scripts/build-android.mjs @@ -73,4 +73,8 @@ for (const p of ['index.html', 'css', 'js', 'assets']) { const from = path.join(root, p); if (fs.existsSync(from)) fs.cpSync(from, path.join(www, p), { recursive: true, filter: s => !s.endsWith('vyla-local.js') }); } -fs.copyFileSync(path.join(root, 'node_modules/hls.js/dist/hls.min.js'), path.join(www, 'js/hls.min.js')); \ No newline at end of file +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')); \ No newline at end of file diff --git a/scripts/build-apk.mjs b/scripts/build-apk.mjs index 1afb653..164b4ba 100644 --- a/scripts/build-apk.mjs +++ b/scripts/build-apk.mjs @@ -28,7 +28,6 @@ if (j.status !== 0) { } run('node', ['scripts/build-android.mjs']); -run('npx', ['cap', 'sync', 'android']); run(win ? 'gradlew.bat' : './gradlew', ['assembleDebug'], androidDir); const apk = path.join(androidDir, 'app/build/outputs/apk/debug/app-debug.apk'); diff --git a/server.js b/server.js deleted file mode 100644 index 793b779..0000000 --- a/server.js +++ /dev/null @@ -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}`); -}); \ No newline at end of file diff --git a/www/css/base.css b/www/css/base.css deleted file mode 100644 index 1e25c73..0000000 --- a/www/css/base.css +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/www/css/detail.css b/www/css/detail.css deleted file mode 100644 index 79bb42d..0000000 --- a/www/css/detail.css +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/www/css/library.css b/www/css/library.css deleted file mode 100644 index 10ab6a8..0000000 --- a/www/css/library.css +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/www/css/player.css b/www/css/player.css deleted file mode 100644 index 4b6b378..0000000 --- a/www/css/player.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/www/css/search.css b/www/css/search.css deleted file mode 100644 index c272fa2..0000000 --- a/www/css/search.css +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/www/css/sidebar.css b/www/css/sidebar.css deleted file mode 100644 index 00e6092..0000000 --- a/www/css/sidebar.css +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/www/css/skeleton.css b/www/css/skeleton.css deleted file mode 100644 index 2a88d24..0000000 --- a/www/css/skeleton.css +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/www/index.html b/www/index.html deleted file mode 100644 index aa5f89a..0000000 --- a/www/index.html +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - Vyla - - - - - - - - - - - - - - -
-
-
-
-
-
-
-
-
-
- -
-

-
-
-
-
-
-
-
-
- - - Search
-
-
-
-
-
-
-

-
-
- -
-
- - -
-
-
- - -
-
-
-
-
-
- - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/www/js/browse.js b/www/js/browse.js deleted file mode 100644 index f5be750..0000000 --- a/www/js/browse.js +++ /dev/null @@ -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 = `

${t}

`; - $('#hmeta').innerHTML = `★ ${(it.vote_average || 0).toFixed(1)}${yr(it)}${it._t === 'movie' ? 'Movie' : 'Series'}${gn(it)}`; - $('#hov').textContent = it.overview || ''; - const l = await logo(it); - if (heroIt === it && l) $('#hlogo').innerHTML = `${t.replace(/` -} - -function card(it, h = true) { - const c = document.createElement('div'); c.className = 'card f'; c.tabIndex = -1; - c.innerHTML = ``; - 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 = `

${g.name} ${mv ? 'Movies' : 'TV Shows'}

${SK.cards(14)}
`; - 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); diff --git a/www/js/core.js b/www/js/core.js deleted file mode 100644 index b7918fb..0000000 --- a/www/js/core.js +++ /dev/null @@ -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 => `${p}`; -const P_WL = '', P_FV = ''; -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(/ { 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(''); -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); diff --git a/www/js/detail.js b/www/js/detail.js deleted file mode 100644 index 7ba0a69..0000000 --- a/www/js/detail.js +++ /dev/null @@ -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 = `
${ic('')}
` + 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 = `
${ic('')}
${(d.vote_average || 0).toFixed(1)}${year}${cert ? `${cert}` : ''}${rt}${(d.genres || []).slice(0, 3).map(g => g.name).join(', ')}
⋮

${d.overview || ''}

MORE
`; - $('#dback')._a = closeDetail; const secs = $('#dsecs'); - const rowOf = (name, kids) => { if (!kids.length) return; const s = document.createElement('section'); s.className = 'ds'; s.innerHTML = `

${name}

`; 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 = '

Episodes

'; - 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 = `${esc(nm(x))}` + ic(''); - [...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', `
${esc(p.name || 'Episode ' + n)}${['Episode ' + n, p.runtime ? p.runtime + 'm' : ''].filter(Boolean).join(' \u2022 ')}`); - 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 = '

No episodes listed.

' - }; - ss.forEach(x => ddl.appendChild(mkf('ddi', `${esc(nm(x))}${x.episode_count} eps`, () => { 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', `
${v.name}▶ ${v.type}`))); - 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', `
${p.profile_path ? '' : p.name[0]}
${p.name}${p.character || ''}`))); - $('#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 } \ No newline at end of file diff --git a/www/js/focus.js b/www/js/focus.js deleted file mode 100644 index 2ba76a9..0000000 --- a/www/js/focus.js +++ /dev/null @@ -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() } \ No newline at end of file diff --git a/www/js/input.js b/www/js/input.js deleted file mode 100644 index 7bf47a2..0000000 --- a/www/js/input.js +++ /dev/null @@ -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; -}; \ No newline at end of file diff --git a/www/js/library.js b/www/js/library.js deleted file mode 100644 index 5c9168c..0000000 --- a/www/js/library.js +++ /dev/null @@ -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]}${a.length} title${a.length === 1 ? '' : 's'}`; - g.innerHTML = ''; - if (!a.length) { g.innerHTML = '

Nothing here yet. Open a title, press the \u22EE button and add it.

'; 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')) } -} diff --git a/www/js/main.js b/www/js/main.js deleted file mode 100644 index ef547ec..0000000 --- a/www/js/main.js +++ /dev/null @@ -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 = '

Could not reach TMDB.

'; return } - await go('discover'); -})(); diff --git a/www/js/native.js b/www/js/native.js deleted file mode 100644 index b0aa31a..0000000 --- a/www/js/native.js +++ /dev/null @@ -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 [] } - } - }; -})(); \ No newline at end of file diff --git a/www/js/player.js b/www/js/player.js deleted file mode 100644 index b42a848..0000000 --- a/www/js/player.js +++ /dev/null @@ -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) => `${p}`; -const SPK = ''; -const PI = { - x: pi(''), - play: pi('', 1), - pause: pi('', 1), - cc: pi(''), - vol: pi(SPK + ''), - mute: pi(SPK + ''), - qlt: pi(''), - set: pi(''), - more: pi(''), - fs: pi(''), - fx: pi(''), - eps: pi(''), - chk: pi('') -}; - -$('#ptop').innerHTML = `
${PI.x}
${PI.qlt}
${PI.cc}
${PI.vol}
${PI.set}
${PI.more}
`; -$('#pbot').innerHTML = `0:00
0:00
${PI.eps}
${PI.fs}
`; -$('#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 = '
Probing providers...
'; - 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 ? `${expandedSources.has(s.provider) ? '▲' : '▼'}` : ''; - - hdr.innerHTML = `${s.label}${countLabel}
${expandIcon}${s.provider === selectedSourceKey ? PI.chk : ''}
`; - 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 = `${u.quality || 'Stream ' + (i + 1)}${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', `${l}${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 = `Timing Offset
`; - 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 = '
'; - 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${esc(c.it.title || c.it.name || '')}`; - 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' : ''), `
${n}${cur ? '' : ''}
${esc(p.name || 'Episode ' + n)}${p.runtime ? p.runtime + 'm' : 'Episode ' + n}`, () => playEp(sn, i))); - }); - const t = car.querySelector('.cur') || car.firstChild; - if (focus && t) setFocus(t); -} - -$('#pep').innerHTML = `

${PI.x}
`; -$('#pnext').innerHTML = 'Next Episode' + pi(''); -$('#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(); -}); \ No newline at end of file diff --git a/www/js/search.js b/www/js/search.js deleted file mode 100644 index 2204daa..0000000 --- a/www/js/search.js +++ /dev/null @@ -1,70 +0,0 @@ -let sqv = '', bgT, stab = 0, SD = { q: '', all: [], mv: [], tv: [] }; -const SVS = ''; -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', `
${esc(x.title || x.name || '')}★ ${(x.vote_average || 0).toFixed(1)}${yr(x)}${x.media_type === 'tv' ? 'Show' : 'Movie'}`, () => 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 = `

${h}

`; sec.lastChild.append(...a.map(tile)); sres.appendChild(sec) }); - if (!n) sres.innerHTML = `

No results${q ? ` for \u201c${esc(q)}\u201d` : ''}

`; - [...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('', '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(() => `
${SK.tiles(9)}
`).join('') -} diff --git a/www/js/sidebar.js b/www/js/sidebar.js deleted file mode 100644 index edb0a85..0000000 --- a/www/js/sidebar.js +++ /dev/null @@ -1,16 +0,0 @@ -const NAV = [ - ['search', 'Search', ic('')], - ['discover', 'Discovery', ic('')], - ['movie', 'Movies', ic('')], - ['tv', 'TV Shows', ic('')], - ['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 + `${n}`; 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' }) } diff --git a/www/js/skeleton.js b/www/js/skeleton.js deleted file mode 100644 index 7358c76..0000000 --- a/www/js/skeleton.js +++ /dev/null @@ -1,13 +0,0 @@ -const SK = { - cards: n => '
'.repeat(n), - tiles: n => '
'.repeat(n), - chips: n => Array.from({ length: n }, (_, i) => `
`).join(''), - pep: n => '
'.repeat(n), - eps: n => '
'.repeat(n), - rows: n => Array.from({ length: n }, () => '
' + SK.cards(14) + '
').join(''), - detail: it => { - const bp = it.backdrop_path || it.poster_path || ''; - return `
` + - `
${'
'.repeat(4)}
${SK.cards(9)}
` - } -}; \ No newline at end of file