Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
064377d7dd | ||
|
|
0819a1beca | ||
|
|
3009f94fe9 | ||
|
|
9b2339b85d | ||
|
|
22229153b9 | ||
|
|
f8be75bd81 | ||
|
|
69308bf696 | ||
|
|
fbe89e14fd |
94
Dockerfile
|
|
@ -1,49 +1,45 @@
|
||||||
# Stage 1: Build Image (Frontend)
|
# Stage 1: Build Frontend
|
||||||
FROM node:20-alpine AS frontend-builder
|
FROM --platform=linux/amd64 node:20-alpine AS frontend-builder
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
COPY frontend-react/package*.json ./
|
COPY frontend-react/package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY frontend-react/ .
|
COPY frontend-react/ .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Build Image (Backend)
|
# Stage 2: Build Backend for linux/amd64
|
||||||
FROM golang:1.24-alpine AS backend-builder
|
FROM --platform=linux/amd64 golang:1.24-alpine AS backend-builder
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
# Install build dependencies
|
|
||||||
RUN apk add --no-cache gcc musl-dev
|
COPY backend/go.mod backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
COPY backend/go.mod backend/go.sum ./
|
|
||||||
RUN go mod download
|
COPY backend/ .
|
||||||
|
# Build static binary for Linux amd64
|
||||||
COPY backend/ .
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o server cmd/server/main.go
|
||||||
# Build static binary for Linux amd64
|
|
||||||
RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o server cmd/server/main.go
|
# Stage 3: Final Image (linux/amd64 only for Synology NAS)
|
||||||
|
FROM --platform=linux/amd64 alpine:latest
|
||||||
# Stage 3: Final Image
|
WORKDIR /app
|
||||||
FROM alpine:latest
|
|
||||||
WORKDIR /app
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache sqlite ca-certificates tzdata
|
||||||
# Install runtime dependencies (sqlite + yt-dlp for video extraction fallback)
|
|
||||||
RUN apk add --no-cache sqlite ca-certificates tzdata python3 py3-pip && \
|
# Copy backend binary
|
||||||
pip3 install --break-system-packages yt-dlp
|
COPY --from=backend-builder /app/backend/server .
|
||||||
|
|
||||||
# Copy backend binary
|
# Copy frontend build to the expected static directory
|
||||||
COPY --from=backend-builder /app/backend/server .
|
COPY --from=frontend-builder /app/frontend/dist ./dist
|
||||||
|
|
||||||
# Copy frontend build to the expected static directory
|
# Create data directory for SQLite database
|
||||||
# The backend expects ../frontend-react/dist relative to itself, or we configure it.
|
RUN mkdir -p /app/data
|
||||||
# Let's align with the standard deployment structure: /app/server and /app/dist
|
|
||||||
COPY --from=frontend-builder /app/frontend/dist ./dist
|
# Environment variables
|
||||||
|
ENV PORT=8000
|
||||||
# Create data directory
|
ENV DATABASE_URL=/app/data/streamflow.db
|
||||||
RUN mkdir -p data
|
ENV TZ=Asia/Ho_Chi_Minh
|
||||||
|
|
||||||
# Environment variables
|
# Expose port
|
||||||
ENV PORT=8000
|
EXPOSE 8000
|
||||||
ENV DATABASE_URL=/app/data/streamflow.db
|
|
||||||
|
# Start server
|
||||||
# Expose port
|
CMD ["./server"]
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
# Start server
|
|
||||||
CMD ["./server"]
|
|
||||||
|
|
|
||||||
61
README.md
|
|
@ -1,4 +1,4 @@
|
||||||
# StreamFlow V3.9
|
# kv-netflix V6
|
||||||
|
|
||||||
A high-performance video streaming web application with a pure Go backend and modern React + Tailwind frontend.
|
A high-performance video streaming web application with a pure Go backend and modern React + Tailwind frontend.
|
||||||
|
|
||||||
|
|
@ -10,6 +10,7 @@ A high-performance video streaming web application with a pure Go backend and mo
|
||||||
- **HLS Streaming** - Native HLS playback with proxy support
|
- **HLS Streaming** - Native HLS playback with proxy support
|
||||||
- **Android TV** - Native TV app with D-pad controls and 10s skip
|
- **Android TV** - Native TV app with D-pad controls and 10s skip
|
||||||
- **PWA Support** - Install as a progressive web app
|
- **PWA Support** - Install as a progressive web app
|
||||||
|
- **Episode Progress Tracking** - Auto-save progress, continue watching with seek
|
||||||
- **Docker Ready** - Multi-stage build for Synology NAS (linux/amd64)
|
- **Docker Ready** - Multi-stage build for Synology NAS (linux/amd64)
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
@ -23,21 +24,45 @@ A high-performance video streaming web application with a pure Go backend and mo
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Docker (Recommended)
|
### Docker (Recommended for Synology NAS)
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- Synology NAS with Container Manager (Docker) installed
|
||||||
|
- SSH access enabled (optional, for CLI) or use Container Manager GUI
|
||||||
|
|
||||||
|
**Option 1: Container Manager GUI (Recommended for Synology)**
|
||||||
|
|
||||||
|
1. Open **Container Manager** on your Synology NAS
|
||||||
|
2. Go to **Registry** tab and add your Forgejo registry:
|
||||||
|
- Registry URL: `git.khoavo.myds.me`
|
||||||
|
- Username: `vndangkhoa`
|
||||||
|
- Password: `Thieugia19`
|
||||||
|
3. Search for `vndangkhoa/kv-netflix` and download `v6` tag
|
||||||
|
4. Create a new container:
|
||||||
|
- **Image**: `git.khoavo.myds.me/vndangkhoa/kv-netflix:v6`
|
||||||
|
- **Container name**: `streamflow`
|
||||||
|
- **Network**: Bridge mode, map port `3478` (local) → `8000` (container)
|
||||||
|
- **Environment**: Add `TZ=Asia/Ho_Chi_Minh`
|
||||||
|
- **Volume**: Create folder `docker/streamflow/data` on NAS, map to `/app/data`
|
||||||
|
- **Restart policy**: `Unless stopped`
|
||||||
|
5. Start the container
|
||||||
|
|
||||||
|
**Option 2: Docker Compose (SSH/CLI)**
|
||||||
|
|
||||||
|
Create `docker-compose.yml` on your NAS:
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml
|
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
streamflow:
|
streamflow:
|
||||||
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3.9
|
image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v6
|
||||||
container_name: streamflow
|
container_name: streamflow
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
ports:
|
ports:
|
||||||
- "3478:8000"
|
- "3478:8000"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=/app/data/streamflow.db
|
- DATABASE_URL=/app/data/streamflow.db
|
||||||
|
- PORT=8000
|
||||||
- TZ=Asia/Ho_Chi_Minh
|
- TZ=Asia/Ho_Chi_Minh
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
|
@ -51,7 +76,14 @@ services:
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Login to registry first
|
||||||
|
docker login git.khoavo.myds.me -u vndangkhoa -p Thieugia19
|
||||||
|
|
||||||
|
# Start container
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
docker-compose logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
Access at: `http://YOUR_NAS_IP:3478`
|
Access at: `http://YOUR_NAS_IP:3478`
|
||||||
|
|
@ -119,7 +151,26 @@ Streamflow/
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
### v3.9 (Current)
|
### v6 (Current)
|
||||||
|
- Episode progress tracking with auto-save (every 5s + on pause)
|
||||||
|
- Continue Watching section with progress bars
|
||||||
|
- Seek to saved position minus 20 seconds on return
|
||||||
|
- Fixed ophim image URLs (migrated to img.ophim.live)
|
||||||
|
- Removed broken wsrv.nl proxy dependency
|
||||||
|
- Episode badge and progress bar in MovieCard
|
||||||
|
- Pushed to Forgejo: `git.khoavo.myds.me/vndangkhoa/kv-netflix:v6`
|
||||||
|
- Docker multi-stage build optimized for Synology NAS (linux/amd64)
|
||||||
|
|
||||||
|
### v4
|
||||||
|
- Deployed v4 to Forgejo and Docker Registry
|
||||||
|
- Refactored frontend and cleaned up repository
|
||||||
|
|
||||||
|
### v3.9.2
|
||||||
|
- Fixed Android TV local IP issue by replacing it with production backend URL
|
||||||
|
- Rebuilt Android TV APK and updated the frontend static bundle
|
||||||
|
|
||||||
|
### v3.9.1
|
||||||
|
- Fix Android TV OOM crash + backend Content-Type headers
|
||||||
- Bundled Android TV APK with the webapp for direct download
|
- Bundled Android TV APK with the webapp for direct download
|
||||||
- Verified D-pad navigation on Android TV app
|
- Verified D-pad navigation on Android TV app
|
||||||
|
|
||||||
|
|
|
||||||
BIN
android-tv/adb_logs.txt
Normal file
|
|
@ -1,88 +1,88 @@
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.streamflow.tv"
|
namespace = "com.streamflow.tv"
|
||||||
compileSdk = 34
|
compileSdk = 34
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.streamflow.tv"
|
applicationId = "com.streamflow.tv"
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 37
|
versionCode = 37
|
||||||
versionName = "3.7.0"
|
versionName = "3.7.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
isShrinkResources = false
|
isShrinkResources = false
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
composeOptions {
|
composeOptions {
|
||||||
kotlinCompilerExtensionVersion = "1.5.8"
|
kotlinCompilerExtensionVersion = "1.5.8"
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "17"
|
jvmTarget = "17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Compose for TV
|
// Compose for TV
|
||||||
implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
|
implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
|
||||||
implementation("androidx.tv:tv-material:1.0.0")
|
implementation("androidx.tv:tv-material:1.0.0")
|
||||||
|
|
||||||
// Core Compose
|
// Core Compose
|
||||||
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
|
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
|
||||||
implementation("androidx.compose.ui:ui")
|
implementation("androidx.compose.ui:ui")
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
implementation("androidx.compose.material:material-icons-extended")
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
implementation("androidx.activity:activity-compose:1.8.2")
|
implementation("androidx.activity:activity-compose:1.8.2")
|
||||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||||
|
|
||||||
// ExoPlayer (Media3)
|
// ExoPlayer (Media3)
|
||||||
implementation("androidx.media3:media3-exoplayer:1.2.1")
|
implementation("androidx.media3:media3-exoplayer:1.2.1")
|
||||||
implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
|
implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
|
||||||
implementation("androidx.media3:media3-ui:1.2.1")
|
implementation("androidx.media3:media3-ui:1.2.1")
|
||||||
implementation("androidx.media3:media3-session:1.2.1")
|
implementation("androidx.media3:media3-session:1.2.1")
|
||||||
|
|
||||||
// Networking
|
// Networking
|
||||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||||
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
|
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
|
||||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
|
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||||
|
|
||||||
// Image loading
|
// Image loading
|
||||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||||
|
|
||||||
// DataStore
|
// DataStore
|
||||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||||
|
|
||||||
// Core Android TV
|
// Core Android TV
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
implementation("androidx.leanback:leanback:1.0.0")
|
implementation("androidx.leanback:leanback:1.0.0")
|
||||||
|
|
||||||
// Debug
|
// Debug
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
108
android-tv/app/proguard-rules.pro
vendored
|
|
@ -1,54 +1,54 @@
|
||||||
# ProGuard rules for StreamFlow TV
|
# ProGuard rules for StreamFlow TV
|
||||||
|
|
||||||
# Keep all app classes (safety net)
|
# Keep all app classes (safety net)
|
||||||
-keep class com.streamflow.tv.** { *; }
|
-keep class com.streamflow.tv.** { *; }
|
||||||
-keepclassmembers class com.streamflow.tv.** { *; }
|
-keepclassmembers class com.streamflow.tv.** { *; }
|
||||||
|
|
||||||
# Moshi
|
# Moshi
|
||||||
-keep class com.squareup.moshi.** { *; }
|
-keep class com.squareup.moshi.** { *; }
|
||||||
-keepclassmembers class * {
|
-keepclassmembers class * {
|
||||||
@com.squareup.moshi.Json <fields>;
|
@com.squareup.moshi.Json <fields>;
|
||||||
}
|
}
|
||||||
-keepclassmembers class * {
|
-keepclassmembers class * {
|
||||||
@com.squareup.moshi.JsonClass <fields>;
|
@com.squareup.moshi.JsonClass <fields>;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Kotlin Metadata (critical for Moshi reflection adapter)
|
# Kotlin Metadata (critical for Moshi reflection adapter)
|
||||||
-keep class kotlin.Metadata { *; }
|
-keep class kotlin.Metadata { *; }
|
||||||
-keepattributes RuntimeVisibleAnnotations
|
-keepattributes RuntimeVisibleAnnotations
|
||||||
-keepattributes RuntimeInvisibleAnnotations
|
-keepattributes RuntimeInvisibleAnnotations
|
||||||
-keepattributes *Annotation*
|
-keepattributes *Annotation*
|
||||||
|
|
||||||
# Retrofit
|
# Retrofit
|
||||||
-dontwarn retrofit2.**
|
-dontwarn retrofit2.**
|
||||||
-keep class retrofit2.** { *; }
|
-keep class retrofit2.** { *; }
|
||||||
-keepattributes Signature
|
-keepattributes Signature
|
||||||
-keepattributes Exceptions
|
-keepattributes Exceptions
|
||||||
-keepclassmembers,allowshrinking,allowobfuscation interface * {
|
-keepclassmembers,allowshrinking,allowobfuscation interface * {
|
||||||
@retrofit2.http.* <methods>;
|
@retrofit2.http.* <methods>;
|
||||||
}
|
}
|
||||||
|
|
||||||
# OkHttp
|
# OkHttp
|
||||||
-dontwarn okhttp3.**
|
-dontwarn okhttp3.**
|
||||||
-dontwarn okio.**
|
-dontwarn okio.**
|
||||||
-keep class okhttp3.** { *; }
|
-keep class okhttp3.** { *; }
|
||||||
-keep class okio.** { *; }
|
-keep class okio.** { *; }
|
||||||
|
|
||||||
# Kotlin Coroutines
|
# Kotlin Coroutines
|
||||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||||
-keepclassmembers class kotlinx.** {
|
-keepclassmembers class kotlinx.** {
|
||||||
volatile <fields>;
|
volatile <fields>;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Coil
|
# Coil
|
||||||
-dontwarn coil.**
|
-dontwarn coil.**
|
||||||
-keep class coil.** { *; }
|
-keep class coil.** { *; }
|
||||||
|
|
||||||
# AndroidX Compose
|
# AndroidX Compose
|
||||||
-keep class androidx.compose.** { *; }
|
-keep class androidx.compose.** { *; }
|
||||||
-dontwarn androidx.compose.**
|
-dontwarn androidx.compose.**
|
||||||
|
|
||||||
# ExoPlayer / Media3
|
# ExoPlayer / Media3
|
||||||
-keep class androidx.media3.** { *; }
|
-keep class androidx.media3.** { *; }
|
||||||
-dontwarn androidx.media3.**
|
-dontwarn androidx.media3.**
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,43 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.software.leanback"
|
android:name="android.software.leanback"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.touchscreen"
|
android:name="android.hardware.touchscreen"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".StreamFlowApp"
|
android:name=".StreamFlowApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:banner="@drawable/app_banner"
|
android:banner="@drawable/app_banner"
|
||||||
android:theme="@style/Theme.StreamFlowTV"
|
android:theme="@style/Theme.StreamFlowTV"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||||
android:screenOrientation="landscape">
|
android:screenOrientation="landscape">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="streamflow" android:host="player" />
|
<data android:scheme="streamflow" android:host="player" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -1,172 +1,172 @@
|
||||||
package com.streamflow.tv
|
package com.streamflow.tv
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.streamflow.tv.data.api.ApiClient
|
import com.streamflow.tv.data.api.ApiClient
|
||||||
import com.streamflow.tv.data.repository.UserDataRepository
|
import com.streamflow.tv.data.repository.UserDataRepository
|
||||||
import com.streamflow.tv.ui.components.SideNavRail
|
import com.streamflow.tv.ui.components.SideNavRail
|
||||||
import com.streamflow.tv.ui.screens.*
|
import com.streamflow.tv.ui.screens.*
|
||||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||||
import com.streamflow.tv.ui.theme.StreamFlowTvTheme
|
import com.streamflow.tv.ui.theme.StreamFlowTvTheme
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
Log.d("MainActivity", "onCreate started")
|
Log.d("MainActivity", "onCreate started")
|
||||||
setContent {
|
setContent {
|
||||||
StreamFlowTvApp()
|
StreamFlowTvApp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StreamFlowTvApp() {
|
fun StreamFlowTvApp() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val userRepo = remember { UserDataRepository(context) }
|
val userRepo = remember { UserDataRepository(context) }
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
var currentTheme by remember { mutableStateOf("default") }
|
var currentTheme by remember { mutableStateOf("default") }
|
||||||
var selectedNavId by remember { mutableStateOf("home") }
|
var selectedNavId by remember { mutableStateOf("home") }
|
||||||
|
|
||||||
// Load persisted settings
|
// Load persisted settings
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
try {
|
try {
|
||||||
currentTheme = userRepo.theme.first()
|
currentTheme = userRepo.theme.first()
|
||||||
val serverUrl = userRepo.serverUrl.first()
|
val serverUrl = userRepo.serverUrl.first()
|
||||||
/*if (serverUrl.isNotBlank()) {
|
if (serverUrl.isNotBlank()) {
|
||||||
ApiClient.baseUrl = serverUrl
|
ApiClient.baseUrl = serverUrl
|
||||||
}*/
|
|
||||||
Log.d("StreamFlowTvApp", "Settings loaded: theme=$currentTheme, url=$serverUrl")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("StreamFlowTvApp", "Error loading settings", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StreamFlowTvTheme(themeName = currentTheme) {
|
|
||||||
val colors = StreamFlowTheme.colors
|
|
||||||
|
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
|
||||||
val currentRoute = navBackStackEntry?.destination?.route
|
|
||||||
val showSideNav = currentRoute != null && !currentRoute.startsWith("player")
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(colors.background)
|
|
||||||
) {
|
|
||||||
// Side Navigation
|
|
||||||
if (showSideNav) {
|
|
||||||
SideNavRail(
|
|
||||||
selectedId = selectedNavId,
|
|
||||||
onNavigate = { item ->
|
|
||||||
selectedNavId = item.id
|
|
||||||
navController.navigate(item.route) {
|
|
||||||
popUpTo("home") { saveState = true }
|
|
||||||
launchSingleTop = true
|
|
||||||
restoreState = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
Log.d("StreamFlowTvApp", "Settings loaded: theme=$currentTheme, url=$serverUrl")
|
||||||
// Main content
|
} catch (e: Exception) {
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
Log.e("StreamFlowTvApp", "Error loading settings", e)
|
||||||
NavHost(
|
}
|
||||||
navController = navController,
|
}
|
||||||
startDestination = "home"
|
|
||||||
) {
|
StreamFlowTvTheme(themeName = currentTheme) {
|
||||||
composable("home") {
|
val colors = StreamFlowTheme.colors
|
||||||
HomeScreen(
|
|
||||||
onMovieClick = { slug ->
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
navController.navigate("detail/$slug")
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
},
|
val showSideNav = currentRoute != null && !currentRoute.startsWith("player")
|
||||||
userDataRepository = userRepo
|
|
||||||
)
|
Row(
|
||||||
}
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
composable(
|
.background(colors.background)
|
||||||
"home/{category}",
|
) {
|
||||||
arguments = listOf(navArgument("category") { type = NavType.StringType })
|
// Side Navigation
|
||||||
) { entry ->
|
if (showSideNav) {
|
||||||
HomeScreen(
|
SideNavRail(
|
||||||
onMovieClick = { slug -> navController.navigate("detail/$slug") },
|
selectedId = selectedNavId,
|
||||||
category = entry.arguments?.getString("category"),
|
onNavigate = { item ->
|
||||||
userDataRepository = userRepo
|
selectedNavId = item.id
|
||||||
)
|
navController.navigate(item.route) {
|
||||||
}
|
popUpTo("home") { saveState = true }
|
||||||
|
launchSingleTop = true
|
||||||
composable(
|
restoreState = true
|
||||||
"detail/{slug}",
|
}
|
||||||
arguments = listOf(navArgument("slug") { type = NavType.StringType })
|
}
|
||||||
) { entry ->
|
)
|
||||||
val slug = entry.arguments?.getString("slug") ?: return@composable
|
}
|
||||||
DetailScreen(
|
|
||||||
slug = slug,
|
// Main content
|
||||||
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
onBack = { navController.popBackStack() }
|
NavHost(
|
||||||
)
|
navController = navController,
|
||||||
}
|
startDestination = "home"
|
||||||
|
) {
|
||||||
composable(
|
composable("home") {
|
||||||
"player/{slug}/{episode}",
|
HomeScreen(
|
||||||
arguments = listOf(
|
onMovieClick = { slug ->
|
||||||
navArgument("slug") { type = NavType.StringType },
|
navController.navigate("detail/$slug")
|
||||||
navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
|
},
|
||||||
),
|
userDataRepository = userRepo
|
||||||
deepLinks = listOf(androidx.navigation.navDeepLink { uriPattern = "streamflow://player/{slug}/{episode}" })
|
)
|
||||||
) { entry ->
|
}
|
||||||
val slug = entry.arguments?.getString("slug")
|
|
||||||
val episode = entry.arguments?.getInt("episode") ?: 1
|
composable(
|
||||||
Log.d("StreamFlowNav", "Navigating to player: slug=$slug, episode=$episode")
|
"home/{category}",
|
||||||
if (slug == null) {
|
arguments = listOf(navArgument("category") { type = NavType.StringType })
|
||||||
return@composable
|
) { entry ->
|
||||||
}
|
HomeScreen(
|
||||||
PlayerScreen(
|
onMovieClick = { slug -> navController.navigate("detail/$slug") },
|
||||||
slug = slug,
|
category = entry.arguments?.getString("category"),
|
||||||
episode = episode,
|
userDataRepository = userRepo
|
||||||
userDataRepository = userRepo
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
composable(
|
||||||
composable("search") {
|
"detail/{slug}",
|
||||||
SearchScreen(
|
arguments = listOf(navArgument("slug") { type = NavType.StringType })
|
||||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
) { entry ->
|
||||||
)
|
val slug = entry.arguments?.getString("slug") ?: return@composable
|
||||||
}
|
DetailScreen(
|
||||||
|
slug = slug,
|
||||||
composable("mylist") {
|
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
|
||||||
MyListScreen(
|
onBack = { navController.popBackStack() }
|
||||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
composable(
|
||||||
composable("settings") {
|
"player/{slug}/{episode}",
|
||||||
SettingsScreen(
|
arguments = listOf(
|
||||||
currentTheme = currentTheme,
|
navArgument("slug") { type = NavType.StringType },
|
||||||
onThemeChange = { theme ->
|
navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
|
||||||
currentTheme = theme
|
),
|
||||||
scope.launch { userRepo.setTheme(theme) }
|
deepLinks = listOf(androidx.navigation.navDeepLink { uriPattern = "streamflow://player/{slug}/{episode}" })
|
||||||
}
|
) { entry ->
|
||||||
)
|
val slug = entry.arguments?.getString("slug")
|
||||||
}
|
val episode = entry.arguments?.getInt("episode") ?: 1
|
||||||
}
|
Log.d("StreamFlowNav", "Navigating to player: slug=$slug, episode=$episode")
|
||||||
}
|
if (slug == null) {
|
||||||
}
|
return@composable
|
||||||
}
|
}
|
||||||
}
|
PlayerScreen(
|
||||||
|
slug = slug,
|
||||||
|
episode = episode,
|
||||||
|
userDataRepository = userRepo
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable("search") {
|
||||||
|
SearchScreen(
|
||||||
|
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable("mylist") {
|
||||||
|
MyListScreen(
|
||||||
|
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable("settings") {
|
||||||
|
SettingsScreen(
|
||||||
|
currentTheme = currentTheme,
|
||||||
|
onThemeChange = { theme ->
|
||||||
|
currentTheme = theme
|
||||||
|
scope.launch { userRepo.setTheme(theme) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
package com.streamflow.tv
|
package com.streamflow.tv
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.ImageLoaderFactory
|
import coil.ImageLoaderFactory
|
||||||
import coil.disk.DiskCache
|
import coil.disk.DiskCache
|
||||||
import coil.memory.MemoryCache
|
import coil.memory.MemoryCache
|
||||||
|
|
||||||
class StreamFlowApp : Application(), ImageLoaderFactory {
|
class StreamFlowApp : Application(), ImageLoaderFactory {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun newImageLoader(): ImageLoader {
|
override fun newImageLoader(): ImageLoader {
|
||||||
return ImageLoader.Builder(this)
|
return ImageLoader.Builder(this)
|
||||||
.memoryCache {
|
.memoryCache {
|
||||||
MemoryCache.Builder(this)
|
MemoryCache.Builder(this)
|
||||||
.maxSizePercent(0.25)
|
.maxSizePercent(0.25)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
.diskCache {
|
.diskCache {
|
||||||
DiskCache.Builder()
|
DiskCache.Builder()
|
||||||
.directory(this.cacheDir.resolve("image_cache"))
|
.directory(this.cacheDir.resolve("image_cache"))
|
||||||
.maxSizePercent(0.02)
|
.maxSizePercent(0.02)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
.respectCacheHeaders(false) // Often needed for some CDNs
|
.respectCacheHeaders(false) // Often needed for some CDNs
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,71 @@
|
||||||
package com.streamflow.tv.data.api
|
package com.streamflow.tv.data.api
|
||||||
|
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
object ApiClient {
|
object ApiClient {
|
||||||
|
|
||||||
// Default base URL for testing
|
// Default base URL for testing
|
||||||
// Change this to your production API when ready
|
// Change this to your production API when ready
|
||||||
// var baseUrl: String = "https://nf.khoavo.myds.me"
|
// private var _baseUrl: String = "http://10.0.2.2:8000/"
|
||||||
private var _baseUrl: String = "http://10.0.2.2:3478/"
|
private var _baseUrl: String = "https://nf.khoavo.myds.me/"
|
||||||
|
|
||||||
var baseUrl: String
|
var baseUrl: String
|
||||||
get() = _baseUrl
|
get() = _baseUrl
|
||||||
set(value) {
|
set(value) {
|
||||||
_baseUrl = if (value.endsWith("/")) value else "$value/"
|
_baseUrl = if (value.endsWith("/")) value else "$value/"
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
_api = null // Reset to rebuild
|
_api = null // Reset to rebuild
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val moshi: Moshi = Moshi.Builder()
|
private val moshi: Moshi = Moshi.Builder()
|
||||||
.addLast(KotlinJsonAdapterFactory())
|
.addLast(KotlinJsonAdapterFactory())
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val userAgentInterceptor = Interceptor { chain ->
|
private val userAgentInterceptor = Interceptor { chain ->
|
||||||
val request = chain.request().newBuilder()
|
val request = chain.request().newBuilder()
|
||||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
.build()
|
.build()
|
||||||
chain.proceed(request)
|
chain.proceed(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
|
private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
|
||||||
.connectTimeout(20, TimeUnit.SECONDS)
|
.connectTimeout(20, TimeUnit.SECONDS)
|
||||||
.readTimeout(60, TimeUnit.SECONDS)
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
.addInterceptor(userAgentInterceptor)
|
.addInterceptor(userAgentInterceptor)
|
||||||
.addInterceptor(
|
.addInterceptor(
|
||||||
HttpLoggingInterceptor().apply {
|
HttpLoggingInterceptor().apply {
|
||||||
level = HttpLoggingInterceptor.Level.HEADERS
|
level = HttpLoggingInterceptor.Level.HEADERS
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private var _api: StreamFlowApi? = null
|
private var _api: StreamFlowApi? = null
|
||||||
|
|
||||||
val api: StreamFlowApi
|
val api: StreamFlowApi
|
||||||
get() {
|
get() {
|
||||||
return synchronized(this) {
|
return synchronized(this) {
|
||||||
if (_api == null) {
|
if (_api == null) {
|
||||||
_api = Retrofit.Builder()
|
_api = Retrofit.Builder()
|
||||||
.baseUrl(_baseUrl)
|
.baseUrl(_baseUrl)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||||
.build()
|
.build()
|
||||||
.create(StreamFlowApi::class.java)
|
.create(StreamFlowApi::class.java)
|
||||||
}
|
}
|
||||||
_api!!
|
_api!!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun imageProxyUrl(url: String, width: Int = 400): String {
|
fun imageProxyUrl(url: String, width: Int = 400): String {
|
||||||
val base = _baseUrl.removeSuffix("/")
|
val base = _baseUrl.removeSuffix("/")
|
||||||
return "$base/api/images/proxy?url=${java.net.URLEncoder.encode(url, "UTF-8")}&width=$width"
|
return "$base/api/images/proxy?url=${java.net.URLEncoder.encode(url, "UTF-8")}&width=$width"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,35 @@
|
||||||
package com.streamflow.tv.data.api
|
package com.streamflow.tv.data.api
|
||||||
|
|
||||||
import com.streamflow.tv.data.model.*
|
import com.streamflow.tv.data.model.*
|
||||||
import retrofit2.http.*
|
import retrofit2.http.*
|
||||||
|
|
||||||
interface StreamFlowApi {
|
interface StreamFlowApi {
|
||||||
|
|
||||||
@GET("api/videos/home")
|
@GET("api/videos/home")
|
||||||
suspend fun getHomeVideos(
|
suspend fun getHomeVideos(
|
||||||
@Query("category") category: String? = null,
|
@Query("category") category: String? = null,
|
||||||
@Query("page") page: Int = 1
|
@Query("page") page: Int = 1
|
||||||
): List<Movie>
|
): List<Movie>
|
||||||
|
|
||||||
@GET("api/videos/search")
|
@GET("api/videos/search")
|
||||||
suspend fun searchVideos(
|
suspend fun searchVideos(
|
||||||
@Query("q") query: String,
|
@Query("q") query: String,
|
||||||
@Query("page") page: Int = 1
|
@Query("page") page: Int = 1
|
||||||
): List<Movie>
|
): List<Movie>
|
||||||
|
|
||||||
@GET("api/videos/{slug}")
|
@GET("api/videos/{slug}")
|
||||||
suspend fun getMovieDetail(
|
suspend fun getMovieDetail(
|
||||||
@Path("slug") slug: String
|
@Path("slug") slug: String
|
||||||
): MovieDetailResponse
|
): MovieDetailResponse
|
||||||
|
|
||||||
@POST("api/extract")
|
@POST("api/extract")
|
||||||
suspend fun extractVideo(
|
suspend fun extractVideo(
|
||||||
@Body request: ExtractRequest
|
@Body request: ExtractRequest
|
||||||
): VideoSource
|
): VideoSource
|
||||||
|
|
||||||
@GET("api/categories/genres")
|
@GET("api/categories/genres")
|
||||||
suspend fun getGenres(): List<Category>
|
suspend fun getGenres(): List<Category>
|
||||||
|
|
||||||
@GET("api/categories/countries")
|
@GET("api/categories/countries")
|
||||||
suspend fun getCountries(): List<Category>
|
suspend fun getCountries(): List<Category>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,113 +1,113 @@
|
||||||
package com.streamflow.tv.data.model
|
package com.streamflow.tv.data.model
|
||||||
|
|
||||||
import com.squareup.moshi.Json
|
import com.squareup.moshi.Json
|
||||||
import com.squareup.moshi.JsonClass
|
import com.squareup.moshi.JsonClass
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
@JsonClass(generateAdapter = false)
|
||||||
data class Movie(
|
data class Movie(
|
||||||
val id: String = "",
|
val id: String = "",
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
@Json(name = "original_title") val originalTitle: String? = null,
|
@Json(name = "original_title") val originalTitle: String? = null,
|
||||||
val slug: String = "",
|
val slug: String = "",
|
||||||
val thumbnail: String = "",
|
val thumbnail: String = "",
|
||||||
val backdrop: String? = null,
|
val backdrop: String? = null,
|
||||||
val quality: String? = null,
|
val quality: String? = null,
|
||||||
val year: Int? = null,
|
val year: Int? = null,
|
||||||
val category: String = "",
|
val category: String = "",
|
||||||
val time: String? = null,
|
val time: String? = null,
|
||||||
val lang: String? = null,
|
val lang: String? = null,
|
||||||
val director: String? = null,
|
val director: String? = null,
|
||||||
val cast: List<String>? = null,
|
val cast: List<String>? = null,
|
||||||
val provider: String? = null
|
val provider: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
@JsonClass(generateAdapter = false)
|
||||||
data class MovieDetail(
|
data class MovieDetail(
|
||||||
val id: String = "",
|
val id: String = "",
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
@Json(name = "original_title") val originalTitle: String? = null,
|
@Json(name = "original_title") val originalTitle: String? = null,
|
||||||
val slug: String = "",
|
val slug: String = "",
|
||||||
val thumbnail: String = "",
|
val thumbnail: String = "",
|
||||||
val backdrop: String? = null,
|
val backdrop: String? = null,
|
||||||
val quality: String? = null,
|
val quality: String? = null,
|
||||||
val year: Int? = null,
|
val year: Int? = null,
|
||||||
val category: String = "",
|
val category: String = "",
|
||||||
val description: String = "",
|
val description: String = "",
|
||||||
val rating: String? = null,
|
val rating: String? = null,
|
||||||
val duration: Int? = null,
|
val duration: Int? = null,
|
||||||
val genre: String? = null,
|
val genre: String? = null,
|
||||||
val director: String? = null,
|
val director: String? = null,
|
||||||
val country: String? = null,
|
val country: String? = null,
|
||||||
val cast: List<String>? = null,
|
val cast: List<String>? = null,
|
||||||
val provider: String? = null,
|
val provider: String? = null,
|
||||||
val episodes: List<Episode>? = null
|
val episodes: List<Episode>? = null
|
||||||
) {
|
) {
|
||||||
fun toMovie(): Movie = Movie(
|
fun toMovie(): Movie = Movie(
|
||||||
id = id,
|
id = id,
|
||||||
title = title,
|
title = title,
|
||||||
originalTitle = originalTitle,
|
originalTitle = originalTitle,
|
||||||
slug = slug,
|
slug = slug,
|
||||||
thumbnail = thumbnail,
|
thumbnail = thumbnail,
|
||||||
backdrop = backdrop,
|
backdrop = backdrop,
|
||||||
quality = quality,
|
quality = quality,
|
||||||
year = year,
|
year = year,
|
||||||
category = category,
|
category = category,
|
||||||
director = director,
|
director = director,
|
||||||
cast = cast,
|
cast = cast,
|
||||||
provider = provider
|
provider = provider
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
@JsonClass(generateAdapter = false)
|
||||||
data class Episode(
|
data class Episode(
|
||||||
val number: Int = 0,
|
val number: Int = 0,
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
val url: String = ""
|
val url: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
@JsonClass(generateAdapter = false)
|
||||||
data class VideoSource(
|
data class VideoSource(
|
||||||
@Json(name = "stream_url") val streamUrl: String = "",
|
@Json(name = "stream_url") val streamUrl: String = "",
|
||||||
val resolution: String = "",
|
val resolution: String = "",
|
||||||
@Json(name = "format_id") val formatId: String = ""
|
@Json(name = "format_id") val formatId: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
@JsonClass(generateAdapter = false)
|
||||||
data class Category(
|
data class Category(
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val slug: String = ""
|
val slug: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
@JsonClass(generateAdapter = false)
|
||||||
data class HomeResponse(
|
data class HomeResponse(
|
||||||
val items: List<Movie> = emptyList(),
|
val items: List<Movie> = emptyList(),
|
||||||
val totalPages: Int = 1,
|
val totalPages: Int = 1,
|
||||||
val currentPage: Int = 1
|
val currentPage: Int = 1
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
@JsonClass(generateAdapter = false)
|
||||||
data class ExtractRequest(
|
data class ExtractRequest(
|
||||||
val url: String
|
val url: String
|
||||||
)
|
)
|
||||||
|
|
||||||
@JsonClass(generateAdapter = false)
|
@JsonClass(generateAdapter = false)
|
||||||
data class MovieDetailResponse(
|
data class MovieDetailResponse(
|
||||||
val id: String = "",
|
val id: String = "",
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
@Json(name = "original_title") val originalTitle: String? = null,
|
@Json(name = "original_title") val originalTitle: String? = null,
|
||||||
val slug: String = "",
|
val slug: String = "",
|
||||||
val thumbnail: String = "",
|
val thumbnail: String = "",
|
||||||
val backdrop: String? = null,
|
val backdrop: String? = null,
|
||||||
val quality: String? = null,
|
val quality: String? = null,
|
||||||
val year: Int? = null,
|
val year: Int? = null,
|
||||||
val category: String = "",
|
val category: String = "",
|
||||||
val description: String = "",
|
val description: String = "",
|
||||||
val rating: String? = null,
|
val rating: String? = null,
|
||||||
val duration: Int? = null,
|
val duration: Int? = null,
|
||||||
val genre: String? = null,
|
val genre: String? = null,
|
||||||
val director: String? = null,
|
val director: String? = null,
|
||||||
val country: String? = null,
|
val country: String? = null,
|
||||||
val cast: List<String>? = null,
|
val cast: List<String>? = null,
|
||||||
val episodes: List<Episode>? = null
|
val episodes: List<Episode>? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,60 @@
|
||||||
package com.streamflow.tv.data.repository
|
package com.streamflow.tv.data.repository
|
||||||
|
|
||||||
import com.streamflow.tv.data.api.ApiClient
|
import com.streamflow.tv.data.api.ApiClient
|
||||||
import com.streamflow.tv.data.model.*
|
import com.streamflow.tv.data.model.*
|
||||||
|
|
||||||
class MovieRepository {
|
class MovieRepository {
|
||||||
|
|
||||||
private val api get() = ApiClient.api
|
private val api get() = ApiClient.api
|
||||||
|
|
||||||
suspend fun getHomeVideos(category: String? = null, page: Int = 1): HomeResponse {
|
suspend fun getHomeVideos(category: String? = null, page: Int = 1): HomeResponse {
|
||||||
val list = api.getHomeVideos(category, page)
|
val list = api.getHomeVideos(category, page)
|
||||||
android.util.Log.e("MovieRepo", "getHomeVideos($category): Received ${list.size} items")
|
android.util.Log.e("MovieRepo", "getHomeVideos($category): Received ${list.size} items")
|
||||||
return HomeResponse(items = list, totalPages = 10, currentPage = page)
|
return HomeResponse(items = list, totalPages = 10, currentPage = page)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun searchVideos(query: String, page: Int = 1): HomeResponse {
|
suspend fun searchVideos(query: String, page: Int = 1): HomeResponse {
|
||||||
val list = api.searchVideos(query, page)
|
val list = api.searchVideos(query, page)
|
||||||
android.util.Log.e("MovieRepo", "searchVideos($query): Received ${list.size} items")
|
android.util.Log.e("MovieRepo", "searchVideos($query): Received ${list.size} items")
|
||||||
return HomeResponse(items = list, totalPages = 1, currentPage = page)
|
return HomeResponse(items = list, totalPages = 1, currentPage = page)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getMovieDetail(slug: String): MovieDetail {
|
suspend fun getMovieDetail(slug: String): MovieDetail {
|
||||||
val response = api.getMovieDetail(slug)
|
val response = api.getMovieDetail(slug)
|
||||||
|
|
||||||
// API returns a flat list of episodes
|
// API returns a flat list of episodes
|
||||||
val episodes = response.episodes ?: emptyList()
|
val episodes = response.episodes ?: emptyList()
|
||||||
|
|
||||||
return MovieDetail(
|
return MovieDetail(
|
||||||
id = response.id,
|
id = response.id,
|
||||||
title = response.title,
|
title = response.title,
|
||||||
originalTitle = response.originalTitle,
|
originalTitle = response.originalTitle,
|
||||||
slug = response.slug,
|
slug = response.slug,
|
||||||
thumbnail = response.thumbnail,
|
thumbnail = response.thumbnail,
|
||||||
backdrop = response.backdrop,
|
backdrop = response.backdrop,
|
||||||
quality = response.quality,
|
quality = response.quality,
|
||||||
year = response.year,
|
year = response.year,
|
||||||
category = response.category,
|
category = response.category,
|
||||||
description = response.description,
|
description = response.description,
|
||||||
rating = response.rating,
|
rating = response.rating,
|
||||||
duration = response.duration,
|
duration = response.duration,
|
||||||
genre = response.genre,
|
genre = response.genre,
|
||||||
director = response.director,
|
director = response.director,
|
||||||
country = response.country,
|
country = response.country,
|
||||||
cast = response.cast,
|
cast = response.cast,
|
||||||
episodes = episodes
|
episodes = episodes
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun extractVideo(url: String): VideoSource {
|
suspend fun extractVideo(url: String): VideoSource {
|
||||||
return api.extractVideo(ExtractRequest(url))
|
return api.extractVideo(ExtractRequest(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getGenres(): List<Category> {
|
suspend fun getGenres(): List<Category> {
|
||||||
return api.getGenres()
|
return api.getGenres()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCountries(): List<Category> {
|
suspend fun getCountries(): List<Category> {
|
||||||
return api.getCountries()
|
return api.getCountries()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,103 +1,103 @@
|
||||||
package com.streamflow.tv.data.repository
|
package com.streamflow.tv.data.repository
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.*
|
import androidx.datastore.preferences.core.*
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import com.squareup.moshi.Types
|
import com.squareup.moshi.Types
|
||||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||||
import com.streamflow.tv.data.model.Movie
|
import com.streamflow.tv.data.model.Movie
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_data")
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_data")
|
||||||
|
|
||||||
class UserDataRepository(private val context: Context) {
|
class UserDataRepository(private val context: Context) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val MY_LIST_KEY = stringPreferencesKey("my_list")
|
private val MY_LIST_KEY = stringPreferencesKey("my_list")
|
||||||
private val WATCH_HISTORY_KEY = stringPreferencesKey("watch_history")
|
private val WATCH_HISTORY_KEY = stringPreferencesKey("watch_history")
|
||||||
private val THEME_KEY = stringPreferencesKey("theme")
|
private val THEME_KEY = stringPreferencesKey("theme")
|
||||||
private val SERVER_URL_KEY = stringPreferencesKey("server_url")
|
private val SERVER_URL_KEY = stringPreferencesKey("server_url")
|
||||||
|
|
||||||
private const val MAX_HISTORY = 50
|
private const val MAX_HISTORY = 50
|
||||||
}
|
}
|
||||||
|
|
||||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||||
private val movieListType = Types.newParameterizedType(List::class.java, Movie::class.java)
|
private val movieListType = Types.newParameterizedType(List::class.java, Movie::class.java)
|
||||||
private val movieListAdapter = moshi.adapter<List<Movie>>(movieListType)
|
private val movieListAdapter = moshi.adapter<List<Movie>>(movieListType)
|
||||||
|
|
||||||
// --- My List ---
|
// --- My List ---
|
||||||
|
|
||||||
val myList: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
|
val myList: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
|
||||||
val json = prefs[MY_LIST_KEY] ?: "[]"
|
val json = prefs[MY_LIST_KEY] ?: "[]"
|
||||||
movieListAdapter.fromJson(json) ?: emptyList()
|
movieListAdapter.fromJson(json) ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun addToMyList(movie: Movie) {
|
suspend fun addToMyList(movie: Movie) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||||
if (current.none { it.slug == movie.slug }) {
|
if (current.none { it.slug == movie.slug }) {
|
||||||
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current + movie)
|
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current + movie)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removeFromMyList(slug: String) {
|
suspend fun removeFromMyList(slug: String) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||||
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current.filter { it.slug != slug })
|
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current.filter { it.slug != slug })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun isInMyList(slug: String): Boolean {
|
suspend fun isInMyList(slug: String): Boolean {
|
||||||
var found = false
|
var found = false
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||||
found = current.any { it.slug == slug }
|
found = current.any { it.slug == slug }
|
||||||
}
|
}
|
||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Watch History ---
|
// --- Watch History ---
|
||||||
|
|
||||||
val watchHistory: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
|
val watchHistory: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
|
||||||
val json = prefs[WATCH_HISTORY_KEY] ?: "[]"
|
val json = prefs[WATCH_HISTORY_KEY] ?: "[]"
|
||||||
movieListAdapter.fromJson(json) ?: emptyList()
|
movieListAdapter.fromJson(json) ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun addToHistory(movie: Movie) {
|
suspend fun addToHistory(movie: Movie) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
val current = movieListAdapter.fromJson(prefs[WATCH_HISTORY_KEY] ?: "[]")?.toMutableList() ?: mutableListOf()
|
val current = movieListAdapter.fromJson(prefs[WATCH_HISTORY_KEY] ?: "[]")?.toMutableList() ?: mutableListOf()
|
||||||
current.removeAll { it.slug == movie.slug }
|
current.removeAll { it.slug == movie.slug }
|
||||||
current.add(0, movie) // Most recent first
|
current.add(0, movie) // Most recent first
|
||||||
val trimmed = current.take(MAX_HISTORY)
|
val trimmed = current.take(MAX_HISTORY)
|
||||||
prefs[WATCH_HISTORY_KEY] = movieListAdapter.toJson(trimmed)
|
prefs[WATCH_HISTORY_KEY] = movieListAdapter.toJson(trimmed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Theme ---
|
// --- Theme ---
|
||||||
|
|
||||||
val theme: Flow<String> = context.dataStore.data.map { prefs ->
|
val theme: Flow<String> = context.dataStore.data.map { prefs ->
|
||||||
prefs[THEME_KEY] ?: "default"
|
prefs[THEME_KEY] ?: "default"
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setTheme(theme: String) {
|
suspend fun setTheme(theme: String) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
prefs[THEME_KEY] = theme
|
prefs[THEME_KEY] = theme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Server URL ---
|
// --- Server URL ---
|
||||||
|
|
||||||
val serverUrl: Flow<String> = context.dataStore.data.map { prefs ->
|
val serverUrl: Flow<String> = context.dataStore.data.map { prefs ->
|
||||||
prefs[SERVER_URL_KEY] ?: "https://nf.khoavo.myds.me"
|
prefs[SERVER_URL_KEY] ?: "https://nf.khoavo.myds.me"
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setServerUrl(url: String) {
|
suspend fun setServerUrl(url: String) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
prefs[SERVER_URL_KEY] = url
|
prefs[SERVER_URL_KEY] = url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,76 @@
|
||||||
package com.streamflow.tv.ui.components
|
package com.streamflow.tv.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||||
import androidx.tv.foundation.lazy.grid.items
|
import androidx.tv.foundation.lazy.grid.items
|
||||||
import androidx.tv.material3.*
|
import androidx.tv.material3.*
|
||||||
import com.streamflow.tv.data.model.Episode
|
import com.streamflow.tv.data.model.Episode
|
||||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun EpisodeSelector(
|
fun EpisodeSelector(
|
||||||
episodes: List<Episode>,
|
episodes: List<Episode>,
|
||||||
currentEpisode: Int,
|
currentEpisode: Int,
|
||||||
onEpisodeSelect: (Episode) -> Unit,
|
onEpisodeSelect: (Episode) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val colors = StreamFlowTheme.colors
|
val colors = StreamFlowTheme.colors
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
Text(
|
Text(
|
||||||
text = "Episodes",
|
text = "Episodes",
|
||||||
style = StreamFlowTheme.typography.headlineMedium,
|
style = StreamFlowTheme.typography.headlineMedium,
|
||||||
modifier = Modifier.padding(bottom = 12.dp)
|
modifier = Modifier.padding(bottom = 12.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
android.util.Log.e("EpisodeSelector", "Rendering grid with ${episodes.size} episodes")
|
android.util.Log.e("EpisodeSelector", "Rendering grid with ${episodes.size} episodes")
|
||||||
TvLazyVerticalGrid(
|
TvLazyVerticalGrid(
|
||||||
columns = TvGridCells.Adaptive(minSize = 120.dp),
|
columns = TvGridCells.Adaptive(minSize = 120.dp),
|
||||||
contentPadding = PaddingValues(4.dp),
|
contentPadding = PaddingValues(4.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
items(episodes) { episode ->
|
items(episodes) { episode ->
|
||||||
val isActive = episode.number == currentEpisode
|
val isActive = episode.number == currentEpisode
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { onEpisodeSelect(episode) },
|
onClick = { onEpisodeSelect(episode) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.onFocusChanged { isFocused = it.isFocused },
|
.onFocusChanged { isFocused = it.isFocused },
|
||||||
shape = ClickableSurfaceDefaults.shape(
|
shape = ClickableSurfaceDefaults.shape(
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
),
|
),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = if (isActive) colors.primary.copy(alpha = 0.2f) else colors.surfaceVariant,
|
containerColor = if (isActive) colors.primary.copy(alpha = 0.2f) else colors.surfaceVariant,
|
||||||
focusedContainerColor = colors.primary.copy(alpha = 0.3f)
|
focusedContainerColor = colors.primary.copy(alpha = 0.3f)
|
||||||
),
|
),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 12.dp, horizontal = 16.dp),
|
.padding(vertical = 12.dp, horizontal = 16.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = if (episode.title.isNotBlank()) episode.title else "Ep ${episode.number}",
|
text = if (episode.title.isNotBlank()) episode.title else "Ep ${episode.number}",
|
||||||
style = StreamFlowTheme.typography.labelLarge.copy(
|
style = StreamFlowTheme.typography.labelLarge.copy(
|
||||||
color = if (isActive) colors.primary else Color.White
|
color = if (isActive) colors.primary else Color.White
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,159 +1,159 @@
|
||||||
package com.streamflow.tv.ui.components
|
package com.streamflow.tv.ui.components
|
||||||
|
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.*
|
import androidx.tv.material3.*
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.streamflow.tv.data.api.ApiClient
|
import com.streamflow.tv.data.api.ApiClient
|
||||||
import com.streamflow.tv.data.model.Movie
|
import com.streamflow.tv.data.model.Movie
|
||||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HeroBanner(
|
fun HeroBanner(
|
||||||
movies: List<Movie>,
|
movies: List<Movie>,
|
||||||
onPlayClick: (Movie) -> Unit,
|
onPlayClick: (Movie) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (movies.isEmpty()) return
|
if (movies.isEmpty()) return
|
||||||
val colors = StreamFlowTheme.colors
|
val colors = StreamFlowTheme.colors
|
||||||
|
|
||||||
var currentIndex by remember { mutableIntStateOf(0) }
|
var currentIndex by remember { mutableIntStateOf(0) }
|
||||||
val currentMovie = movies[currentIndex]
|
val currentMovie = movies[currentIndex]
|
||||||
|
|
||||||
LaunchedEffect(currentIndex) {
|
LaunchedEffect(currentIndex) {
|
||||||
delay(6000)
|
delay(6000)
|
||||||
currentIndex = (currentIndex + 1) % movies.size
|
currentIndex = (currentIndex + 1) % movies.size
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(480.dp)
|
.height(480.dp)
|
||||||
) {
|
) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = currentMovie,
|
targetState = currentMovie,
|
||||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||||
label = "hero-crossfade"
|
label = "hero-crossfade"
|
||||||
) { movie ->
|
) { movie ->
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
|
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
|
||||||
contentDescription = movie.title,
|
contentDescription = movie.title,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(
|
.background(
|
||||||
Brush.horizontalGradient(
|
Brush.horizontalGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
colors.background.copy(alpha = 0.9f),
|
colors.background.copy(alpha = 0.9f),
|
||||||
colors.background.copy(alpha = 0.5f),
|
colors.background.copy(alpha = 0.5f),
|
||||||
Color.Transparent
|
Color.Transparent
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.fillMaxHeight(0.4f)
|
.fillMaxHeight(0.4f)
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.background(
|
.background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
colors = listOf(Color.Transparent, colors.background)
|
colors = listOf(Color.Transparent, colors.background)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.CenterStart)
|
.align(Alignment.CenterStart)
|
||||||
.padding(start = 48.dp, end = 200.dp)
|
.padding(start = 48.dp, end = 200.dp)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
currentMovie.quality?.let { quality ->
|
currentMovie.quality?.let { quality ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(colors.primary, RoundedCornerShape(4.dp))
|
.background(colors.primary, RoundedCornerShape(4.dp))
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = quality,
|
text = quality,
|
||||||
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
|
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = currentMovie.title,
|
text = currentMovie.title,
|
||||||
style = StreamFlowTheme.typography.displayLarge,
|
style = StreamFlowTheme.typography.displayLarge,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
currentMovie.year?.let {
|
currentMovie.year?.let {
|
||||||
Text("$it", style = StreamFlowTheme.typography.bodyLarge)
|
Text("$it", style = StreamFlowTheme.typography.bodyLarge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { onPlayClick(currentMovie) },
|
onClick = { onPlayClick(currentMovie) },
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = colors.primary,
|
containerColor = colors.primary,
|
||||||
focusedContainerColor = colors.accent
|
focusedContainerColor = colors.accent
|
||||||
),
|
),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "▶ Play Now",
|
text = "▶ Play Now",
|
||||||
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
|
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
|
||||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(bottom = 16.dp),
|
.padding(bottom = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
movies.forEachIndexed { index, _ ->
|
movies.forEachIndexed { index, _ ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(if (index == currentIndex) 24.dp else 8.dp, 8.dp)
|
.size(if (index == currentIndex) 24.dp else 8.dp, 8.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.background(
|
||||||
if (index == currentIndex) colors.primary
|
if (index == currentIndex) colors.primary
|
||||||
else Color.White.copy(alpha = 0.3f)
|
else Color.White.copy(alpha = 0.3f)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,116 +1,116 @@
|
||||||
package com.streamflow.tv.ui.components
|
package com.streamflow.tv.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.*
|
import androidx.tv.material3.*
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.streamflow.tv.data.api.ApiClient
|
import com.streamflow.tv.data.api.ApiClient
|
||||||
import com.streamflow.tv.data.model.Movie
|
import com.streamflow.tv.data.model.Movie
|
||||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MovieCard(
|
fun MovieCard(
|
||||||
movie: Movie,
|
movie: Movie,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val colors = StreamFlowTheme.colors
|
val colors = StreamFlowTheme.colors
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(200.dp)
|
.width(200.dp)
|
||||||
.height(300.dp),
|
.height(300.dp),
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = colors.surfaceVariant,
|
containerColor = colors.surfaceVariant,
|
||||||
focusedContainerColor = colors.surfaceVariant
|
focusedContainerColor = colors.surfaceVariant
|
||||||
),
|
),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f)
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f)
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = ApiClient.imageProxyUrl(movie.thumbnail, 300),
|
model = ApiClient.imageProxyUrl(movie.thumbnail, 300),
|
||||||
contentDescription = movie.title,
|
contentDescription = movie.title,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
)
|
)
|
||||||
|
|
||||||
movie.quality?.let { quality ->
|
movie.quality?.let { quality ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(8.dp)
|
.padding(8.dp)
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.background(colors.primary, RoundedCornerShape(4.dp))
|
.background(colors.primary, RoundedCornerShape(4.dp))
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = quality,
|
text = quality,
|
||||||
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
|
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
movie.provider?.let { provider ->
|
movie.provider?.let { provider ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(8.dp)
|
.padding(8.dp)
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(4.dp))
|
.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(4.dp))
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = provider,
|
text = provider,
|
||||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||||
color = Color.White.copy(alpha = 0.8f),
|
color = Color.White.copy(alpha = 0.8f),
|
||||||
fontSize = androidx.compose.ui.unit.TextUnit.Unspecified // Default or small
|
fontSize = androidx.compose.ui.unit.TextUnit.Unspecified // Default or small
|
||||||
),
|
),
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.background(
|
.background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f))
|
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.padding(horizontal = 10.dp, vertical = 10.dp)
|
.padding(horizontal = 10.dp, vertical = 10.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = movie.title,
|
text = movie.title,
|
||||||
style = StreamFlowTheme.typography.labelLarge,
|
style = StreamFlowTheme.typography.labelLarge,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
|
||||||
movie.year?.let { year ->
|
movie.year?.let { year ->
|
||||||
Text(
|
Text(
|
||||||
text = year.toString(),
|
text = year.toString(),
|
||||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||||
color = Color.White.copy(alpha = 0.6f)
|
color = Color.White.copy(alpha = 0.6f)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,43 @@
|
||||||
package com.streamflow.tv.ui.components
|
package com.streamflow.tv.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.foundation.lazy.list.TvLazyRow
|
import androidx.tv.foundation.lazy.list.TvLazyRow
|
||||||
import androidx.tv.foundation.lazy.list.items
|
import androidx.tv.foundation.lazy.list.items
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.streamflow.tv.data.model.Movie
|
import com.streamflow.tv.data.model.Movie
|
||||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MovieRow(
|
fun MovieRow(
|
||||||
title: String,
|
title: String,
|
||||||
movies: List<Movie>,
|
movies: List<Movie>,
|
||||||
onMovieClick: (Movie) -> Unit,
|
onMovieClick: (Movie) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier.padding(vertical = 12.dp)) {
|
Column(modifier = modifier.padding(vertical = 12.dp)) {
|
||||||
// Section title
|
// Section title
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = StreamFlowTheme.typography.headlineMedium,
|
style = StreamFlowTheme.typography.headlineMedium,
|
||||||
modifier = Modifier.padding(start = 48.dp, bottom = 12.dp)
|
modifier = Modifier.padding(start = 48.dp, bottom = 12.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Horizontal scrolling row of cards
|
// Horizontal scrolling row of cards
|
||||||
TvLazyRow(
|
TvLazyRow(
|
||||||
contentPadding = PaddingValues(horizontal = 48.dp),
|
contentPadding = PaddingValues(horizontal = 48.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
items(movies) { movie ->
|
items(movies) { movie ->
|
||||||
MovieCard(
|
MovieCard(
|
||||||
movie = movie,
|
movie = movie,
|
||||||
onClick = { onMovieClick(movie) }
|
onClick = { onMovieClick(movie) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,127 +1,127 @@
|
||||||
package com.streamflow.tv.ui.components
|
package com.streamflow.tv.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.*
|
import androidx.tv.material3.*
|
||||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
|
||||||
data class NavItem(
|
data class NavItem(
|
||||||
val id: String,
|
val id: String,
|
||||||
val route: String,
|
val route: String,
|
||||||
val label: String,
|
val label: String,
|
||||||
val icon: ImageVector
|
val icon: ImageVector
|
||||||
)
|
)
|
||||||
|
|
||||||
val NAV_ITEMS = listOf(
|
val NAV_ITEMS = listOf(
|
||||||
NavItem("home", "home", "Home", Icons.Default.Home),
|
NavItem("home", "home", "Home", Icons.Default.Home),
|
||||||
NavItem("categories", "home/phim-le", "Categories", Icons.Default.Category),
|
NavItem("categories", "home/phim-le", "Categories", Icons.Default.Category),
|
||||||
NavItem("search", "search", "Search", Icons.Default.Search),
|
NavItem("search", "search", "Search", Icons.Default.Search),
|
||||||
NavItem("mylist", "mylist", "My List", Icons.Default.Favorite),
|
NavItem("mylist", "mylist", "My List", Icons.Default.Favorite),
|
||||||
NavItem("settings", "settings", "Settings", Icons.Default.Settings)
|
NavItem("settings", "settings", "Settings", Icons.Default.Settings)
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SideNavRail(
|
fun SideNavRail(
|
||||||
selectedId: String,
|
selectedId: String,
|
||||||
onNavigate: (NavItem) -> Unit,
|
onNavigate: (NavItem) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val colors = StreamFlowTheme.colors
|
val colors = StreamFlowTheme.colors
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
try {
|
try {
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Ignore
|
// Ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.width(56.dp)
|
.width(56.dp)
|
||||||
.background(colors.background.copy(alpha = 0.95f))
|
.background(colors.background.copy(alpha = 0.95f))
|
||||||
.padding(vertical = 16.dp),
|
.padding(vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(36.dp)
|
.size(36.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(colors.primary),
|
.background(colors.primary),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text("S", style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White))
|
Text("S", style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
NAV_ITEMS.forEach { item ->
|
NAV_ITEMS.forEach { item ->
|
||||||
NavRailItem(
|
NavRailItem(
|
||||||
item = item,
|
item = item,
|
||||||
isSelected = selectedId == item.id,
|
isSelected = selectedId == item.id,
|
||||||
onClick = { onNavigate(item) },
|
onClick = { onNavigate(item) },
|
||||||
accentColor = colors.primary,
|
accentColor = colors.primary,
|
||||||
modifier = if (item.id == "home") Modifier.focusRequester(focusRequester) else Modifier
|
modifier = if (item.id == "home") Modifier.focusRequester(focusRequester) else Modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun NavRailItem(
|
private fun NavRailItem(
|
||||||
item: NavItem,
|
item: NavItem,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
accentColor: Color,
|
accentColor: Color,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(48.dp),
|
.size(48.dp),
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = if (isSelected) accentColor.copy(alpha = 0.15f) else Color.Transparent,
|
containerColor = if (isSelected) accentColor.copy(alpha = 0.15f) else Color.Transparent,
|
||||||
focusedContainerColor = accentColor.copy(alpha = 0.2f)
|
focusedContainerColor = accentColor.copy(alpha = 0.2f)
|
||||||
),
|
),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f)
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = item.icon,
|
imageVector = item.icon,
|
||||||
contentDescription = item.label,
|
contentDescription = item.label,
|
||||||
tint = if (isSelected) accentColor else Color.White.copy(alpha = 0.6f),
|
tint = if (isSelected) accentColor else Color.White.copy(alpha = 0.6f),
|
||||||
modifier = Modifier.size(22.dp)
|
modifier = Modifier.size(22.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,98 +1,98 @@
|
||||||
package com.streamflow.tv.ui.navigation
|
package com.streamflow.tv.ui.navigation
|
||||||
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.streamflow.tv.ui.screens.*
|
import com.streamflow.tv.ui.screens.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavigation(
|
fun AppNavigation(
|
||||||
currentTheme: String,
|
currentTheme: String,
|
||||||
onThemeChange: (String) -> Unit
|
onThemeChange: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
NavHost(navController = navController, startDestination = "home") {
|
NavHost(navController = navController, startDestination = "home") {
|
||||||
// Home (all categories)
|
// Home (all categories)
|
||||||
composable("home") {
|
composable("home") {
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Home filtered by category
|
// Home filtered by category
|
||||||
composable(
|
composable(
|
||||||
"home/{category}",
|
"home/{category}",
|
||||||
arguments = listOf(navArgument("category") { type = NavType.StringType })
|
arguments = listOf(navArgument("category") { type = NavType.StringType })
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val category = backStackEntry.arguments?.getString("category")
|
val category = backStackEntry.arguments?.getString("category")
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
onMovieClick = { slug -> navController.navigate("detail/$slug") },
|
onMovieClick = { slug -> navController.navigate("detail/$slug") },
|
||||||
category = category
|
category = category
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Movie Detail
|
// Movie Detail
|
||||||
composable(
|
composable(
|
||||||
"detail/{slug}",
|
"detail/{slug}",
|
||||||
arguments = listOf(navArgument("slug") { type = NavType.StringType })
|
arguments = listOf(navArgument("slug") { type = NavType.StringType })
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
|
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
|
||||||
DetailScreen(
|
DetailScreen(
|
||||||
slug = slug,
|
slug = slug,
|
||||||
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
|
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
|
||||||
onBack = { navController.popBackStack() }
|
onBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video Player
|
// Video Player
|
||||||
composable(
|
composable(
|
||||||
"player/{slug}/{episode}",
|
"player/{slug}/{episode}",
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
navArgument("slug") { type = NavType.StringType },
|
navArgument("slug") { type = NavType.StringType },
|
||||||
navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
|
navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
|
||||||
)
|
)
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
|
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
|
||||||
val episode = backStackEntry.arguments?.getInt("episode") ?: 1
|
val episode = backStackEntry.arguments?.getInt("episode") ?: 1
|
||||||
PlayerScreen(slug = slug, episode = episode)
|
PlayerScreen(slug = slug, episode = episode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
composable("search") {
|
composable("search") {
|
||||||
SearchScreen(
|
SearchScreen(
|
||||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// My List
|
// My List
|
||||||
composable("mylist") {
|
composable("mylist") {
|
||||||
MyListScreen(
|
MyListScreen(
|
||||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
composable("settings") {
|
composable("settings") {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
currentTheme = currentTheme,
|
currentTheme = currentTheme,
|
||||||
onThemeChange = onThemeChange
|
onThemeChange = onThemeChange
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose navController for SideNavRail
|
// Expose navController for SideNavRail
|
||||||
LaunchedEffect(navController) {
|
LaunchedEffect(navController) {
|
||||||
// Store nav controller reference for side nav
|
// Store nav controller reference for side nav
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provide nav controller via local
|
// Provide nav controller via local
|
||||||
CompositionLocalProvider(LocalNavController provides navController) {}
|
CompositionLocalProvider(LocalNavController provides navController) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalNavController = staticCompositionLocalOf<androidx.navigation.NavHostController> {
|
val LocalNavController = staticCompositionLocalOf<androidx.navigation.NavHostController> {
|
||||||
error("NavController not provided")
|
error("NavController not provided")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,200 +1,200 @@
|
||||||
package com.streamflow.tv.ui.screens
|
package com.streamflow.tv.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.streamflow.tv.data.api.ApiClient
|
import com.streamflow.tv.data.api.ApiClient
|
||||||
import com.streamflow.tv.data.model.Episode
|
import com.streamflow.tv.data.model.Episode
|
||||||
import com.streamflow.tv.ui.components.EpisodeSelector
|
import com.streamflow.tv.ui.components.EpisodeSelector
|
||||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||||
import com.streamflow.tv.viewmodel.DetailViewModel
|
import com.streamflow.tv.viewmodel.DetailViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DetailScreen(
|
fun DetailScreen(
|
||||||
slug: String,
|
slug: String,
|
||||||
onPlayClick: (String, Int) -> Unit,
|
onPlayClick: (String, Int) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
viewModel: DetailViewModel = viewModel()
|
viewModel: DetailViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val colors = StreamFlowTheme.colors
|
val colors = StreamFlowTheme.colors
|
||||||
|
|
||||||
LaunchedEffect(slug) {
|
LaunchedEffect(slug) {
|
||||||
viewModel.loadMovie(slug)
|
viewModel.loadMovie(slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d("DetailScreen", "Composing DetailScreen(slug=$slug, isLoading=${uiState.isLoading})")
|
Log.d("DetailScreen", "Composing DetailScreen(slug=$slug, isLoading=${uiState.isLoading})")
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(colors.background),
|
.background(colors.background),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (uiState.isLoading) {
|
if (uiState.isLoading) {
|
||||||
CircularLoadingIndicator()
|
CircularLoadingIndicator()
|
||||||
} else if (uiState.error != null) {
|
} else if (uiState.error != null) {
|
||||||
ErrorState(message = uiState.error ?: "Unknown error", onRetry = { viewModel.loadMovie(slug) })
|
ErrorState(message = uiState.error ?: "Unknown error", onRetry = { viewModel.loadMovie(slug) })
|
||||||
} else {
|
} else {
|
||||||
val movie = uiState.movie ?: return@Box
|
val movie = uiState.movie ?: return@Box
|
||||||
Log.d("DetailScreen", "Rendering movie details: ${movie.title}")
|
Log.d("DetailScreen", "Rendering movie details: ${movie.title}")
|
||||||
|
|
||||||
// Background Image
|
// Background Image
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
|
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Gradient Overlays
|
// Gradient Overlays
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(
|
.background(
|
||||||
Brush.horizontalGradient(
|
Brush.horizontalGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
colors.background.copy(alpha = 0.95f),
|
colors.background.copy(alpha = 0.95f),
|
||||||
colors.background.copy(alpha = 0.7f),
|
colors.background.copy(alpha = 0.7f),
|
||||||
Color.Transparent
|
Color.Transparent
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.fillMaxHeight(0.3f)
|
.fillMaxHeight(0.3f)
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.background(
|
.background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
colors = listOf(Color.Transparent, colors.background)
|
colors = listOf(Color.Transparent, colors.background)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(uiState.movie) {
|
LaunchedEffect(uiState.movie) {
|
||||||
if (uiState.movie != null) {
|
if (uiState.movie != null) {
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 48.dp, vertical = 32.dp),
|
.padding(horizontal = 48.dp, vertical = 32.dp),
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = movie.title,
|
text = movie.title,
|
||||||
style = StreamFlowTheme.typography.displayLarge,
|
style = StreamFlowTheme.typography.displayLarge,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = movie.description,
|
text = movie.description,
|
||||||
style = StreamFlowTheme.typography.bodyMedium,
|
style = StreamFlowTheme.typography.bodyMedium,
|
||||||
maxLines = 3,
|
maxLines = 3,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.widthIn(max = 600.dp)
|
modifier = Modifier.widthIn(max = 600.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { onPlayClick(movie.slug, 1) },
|
onClick = { onPlayClick(movie.slug, 1) },
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = colors.primary,
|
containerColor = colors.primary,
|
||||||
focusedContainerColor = colors.accent
|
focusedContainerColor = colors.accent
|
||||||
),
|
),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
|
||||||
modifier = Modifier.focusRequester(focusRequester)
|
modifier = Modifier.focusRequester(focusRequester)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"▶ Play",
|
"▶ Play",
|
||||||
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
|
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
|
||||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!movie.episodes.isNullOrEmpty()) {
|
if (!movie.episodes.isNullOrEmpty()) {
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
EpisodeSelector(
|
EpisodeSelector(
|
||||||
episodes = movie.episodes,
|
episodes = movie.episodes,
|
||||||
currentEpisode = 1,
|
currentEpisode = 1,
|
||||||
onEpisodeSelect = { episode -> onPlayClick(movie.slug, episode.number) },
|
onEpisodeSelect = { episode -> onPlayClick(movie.slug, episode.number) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(200.dp)
|
.height(200.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CircularLoadingIndicator() {
|
fun CircularLoadingIndicator() {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
text = "Loading...",
|
text = "Loading...",
|
||||||
style = StreamFlowTheme.typography.headlineMedium.copy(color = StreamFlowTheme.colors.primary)
|
style = StreamFlowTheme.typography.headlineMedium.copy(color = StreamFlowTheme.colors.primary)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ErrorState(message: String, onRetry: () -> Unit) {
|
fun ErrorState(message: String, onRetry: () -> Unit) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
val colors = StreamFlowTheme.colors
|
val colors = StreamFlowTheme.colors
|
||||||
Text(
|
Text(
|
||||||
text = message,
|
text = message,
|
||||||
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red),
|
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red),
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
)
|
)
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onRetry,
|
onClick = onRetry,
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = colors.surfaceVariant
|
containerColor = colors.surfaceVariant
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Retry",
|
"Retry",
|
||||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,112 +1,112 @@
|
||||||
package com.streamflow.tv.ui.screens
|
package com.streamflow.tv.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.tv.foundation.lazy.list.TvLazyColumn
|
import androidx.tv.foundation.lazy.list.TvLazyColumn
|
||||||
import androidx.tv.foundation.lazy.list.items
|
import androidx.tv.foundation.lazy.list.items
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.streamflow.tv.ui.components.HeroBanner
|
import com.streamflow.tv.ui.components.HeroBanner
|
||||||
import com.streamflow.tv.ui.components.MovieRow
|
import com.streamflow.tv.ui.components.MovieRow
|
||||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||||
import com.streamflow.tv.viewmodel.HomeViewModel
|
import com.streamflow.tv.viewmodel.HomeViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
onMovieClick: (String) -> Unit,
|
onMovieClick: (String) -> Unit,
|
||||||
category: String? = null,
|
category: String? = null,
|
||||||
userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null,
|
userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null,
|
||||||
viewModel: HomeViewModel = viewModel()
|
viewModel: HomeViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val colors = StreamFlowTheme.colors
|
val colors = StreamFlowTheme.colors
|
||||||
|
|
||||||
LaunchedEffect(category) {
|
LaunchedEffect(category) {
|
||||||
viewModel.loadHome(category, userDataRepository)
|
viewModel.loadHome(category, userDataRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(colors.background)
|
.background(colors.background)
|
||||||
) {
|
) {
|
||||||
if (uiState.isLoading) {
|
if (uiState.isLoading) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Loading...",
|
text = "Loading...",
|
||||||
style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary)
|
style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (uiState.error != null) {
|
} else if (uiState.error != null) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = uiState.error ?: "Unknown error",
|
text = uiState.error ?: "Unknown error",
|
||||||
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red)
|
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
TvLazyColumn(
|
TvLazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 24.dp)
|
contentPadding = PaddingValues(bottom = 24.dp)
|
||||||
) {
|
) {
|
||||||
// Hero Banner
|
// Hero Banner
|
||||||
if (uiState.heroMovies.isNotEmpty()) {
|
if (uiState.heroMovies.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
HeroBanner(
|
HeroBanner(
|
||||||
movies = uiState.heroMovies,
|
movies = uiState.heroMovies,
|
||||||
onPlayClick = { movie -> onMovieClick(movie.slug) }
|
onPlayClick = { movie -> onMovieClick(movie.slug) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue Watching (Watch History)
|
// Continue Watching (Watch History)
|
||||||
if (uiState.watchedMovies.isNotEmpty()) {
|
if (uiState.watchedMovies.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
MovieRow(
|
MovieRow(
|
||||||
title = "Continue Watching",
|
title = "Continue Watching",
|
||||||
movies = uiState.watchedMovies,
|
movies = uiState.watchedMovies,
|
||||||
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recommended for You
|
// Recommended for You
|
||||||
if (uiState.recommendedMovies.isNotEmpty()) {
|
if (uiState.recommendedMovies.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
MovieRow(
|
MovieRow(
|
||||||
title = "Recommended for You",
|
title = "Recommended for You",
|
||||||
movies = uiState.recommendedMovies,
|
movies = uiState.recommendedMovies,
|
||||||
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category rows
|
// Category rows
|
||||||
uiState.categoryMovies.forEach { (title, movies) ->
|
uiState.categoryMovies.forEach { (title, movies) ->
|
||||||
if (movies.isNotEmpty()) {
|
if (movies.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
MovieRow(
|
MovieRow(
|
||||||
title = title,
|
title = title,
|
||||||
movies = movies,
|
movies = movies,
|
||||||
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,104 +1,104 @@
|
||||||
package com.streamflow.tv.ui.screens
|
package com.streamflow.tv.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||||
import androidx.tv.foundation.lazy.grid.items
|
import androidx.tv.foundation.lazy.grid.items
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.streamflow.tv.ui.components.MovieCard
|
import com.streamflow.tv.ui.components.MovieCard
|
||||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||||
import com.streamflow.tv.viewmodel.MyListViewModel
|
import com.streamflow.tv.viewmodel.MyListViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MyListScreen(
|
fun MyListScreen(
|
||||||
onMovieClick: (String) -> Unit,
|
onMovieClick: (String) -> Unit,
|
||||||
viewModel: MyListViewModel = viewModel()
|
viewModel: MyListViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val colors = StreamFlowTheme.colors
|
val colors = StreamFlowTheme.colors
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(colors.background)
|
.background(colors.background)
|
||||||
.padding(horizontal = 48.dp, vertical = 32.dp)
|
.padding(horizontal = 48.dp, vertical = 32.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "My List",
|
text = "My List",
|
||||||
style = StreamFlowTheme.typography.displayMedium,
|
style = StreamFlowTheme.typography.displayMedium,
|
||||||
modifier = Modifier.padding(bottom = 24.dp)
|
modifier = Modifier.padding(bottom = 24.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (uiState.watchHistory.isEmpty() && uiState.savedMovies.isEmpty()) {
|
if (uiState.watchHistory.isEmpty() && uiState.savedMovies.isEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text("❤️", style = StreamFlowTheme.typography.displayLarge)
|
Text("❤️", style = StreamFlowTheme.typography.displayLarge)
|
||||||
Text(
|
Text(
|
||||||
"Your list is empty.",
|
"Your list is empty.",
|
||||||
style = StreamFlowTheme.typography.headlineMedium,
|
style = StreamFlowTheme.typography.headlineMedium,
|
||||||
modifier = Modifier.padding(top = 12.dp)
|
modifier = Modifier.padding(top = 12.dp)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Start watching or add movies to your list.",
|
"Start watching or add movies to your list.",
|
||||||
style = StreamFlowTheme.typography.bodyLarge,
|
style = StreamFlowTheme.typography.bodyLarge,
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Continue Watching
|
// Continue Watching
|
||||||
if (uiState.watchHistory.isNotEmpty()) {
|
if (uiState.watchHistory.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Continue Watching",
|
text = "Continue Watching",
|
||||||
style = StreamFlowTheme.typography.headlineMedium,
|
style = StreamFlowTheme.typography.headlineMedium,
|
||||||
modifier = Modifier.padding(bottom = 12.dp)
|
modifier = Modifier.padding(bottom = 12.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
TvLazyVerticalGrid(
|
TvLazyVerticalGrid(
|
||||||
columns = TvGridCells.Adaptive(180.dp),
|
columns = TvGridCells.Adaptive(180.dp),
|
||||||
contentPadding = PaddingValues(4.dp),
|
contentPadding = PaddingValues(4.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
modifier = Modifier.heightIn(max = 320.dp)
|
modifier = Modifier.heightIn(max = 320.dp)
|
||||||
) {
|
) {
|
||||||
items(uiState.watchHistory, key = { "h_${it.slug}" }) { movie ->
|
items(uiState.watchHistory, key = { "h_${it.slug}" }) { movie ->
|
||||||
MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) })
|
MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saved
|
// Saved
|
||||||
if (uiState.savedMovies.isNotEmpty()) {
|
if (uiState.savedMovies.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Saved Movies",
|
text = "Saved Movies",
|
||||||
style = StreamFlowTheme.typography.headlineMedium,
|
style = StreamFlowTheme.typography.headlineMedium,
|
||||||
modifier = Modifier.padding(bottom = 12.dp)
|
modifier = Modifier.padding(bottom = 12.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
TvLazyVerticalGrid(
|
TvLazyVerticalGrid(
|
||||||
columns = TvGridCells.Adaptive(180.dp),
|
columns = TvGridCells.Adaptive(180.dp),
|
||||||
contentPadding = PaddingValues(4.dp),
|
contentPadding = PaddingValues(4.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
items(uiState.savedMovies, key = { "s_${it.slug}" }) { movie ->
|
items(uiState.savedMovies, key = { "s_${it.slug}" }) { movie ->
|
||||||
MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) })
|
MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,249 +1,249 @@
|
||||||
package com.streamflow.tv.ui.screens
|
package com.streamflow.tv.ui.screens
|
||||||
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.key.*
|
import androidx.compose.ui.input.key.*
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||||
import androidx.media3.datasource.DefaultDataSource
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||||
import com.streamflow.tv.viewmodel.PlayerViewModel
|
import com.streamflow.tv.viewmodel.PlayerViewModel
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
|
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PlayerScreen(
|
fun PlayerScreen(
|
||||||
slug: String,
|
slug: String,
|
||||||
episode: Int = 1,
|
episode: Int = 1,
|
||||||
userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null,
|
userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null,
|
||||||
viewModel: PlayerViewModel = viewModel()
|
viewModel: PlayerViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val colors = StreamFlowTheme.colors
|
val colors = StreamFlowTheme.colors
|
||||||
var playerView by remember { mutableStateOf<PlayerView?>(null) }
|
var playerView by remember { mutableStateOf<PlayerView?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(slug, episode) {
|
LaunchedEffect(slug, episode) {
|
||||||
viewModel.loadPlayer(slug, episode)
|
viewModel.loadPlayer(slug, episode)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(uiState.movie) {
|
LaunchedEffect(uiState.movie) {
|
||||||
if (uiState.movie != null && userDataRepository != null) {
|
if (uiState.movie != null && userDataRepository != null) {
|
||||||
viewModel.saveToHistory(userDataRepository)
|
viewModel.saveToHistory(userDataRepository)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExoPlayer instance
|
// ExoPlayer instance
|
||||||
val exoPlayer = remember {
|
val exoPlayer = remember {
|
||||||
ExoPlayer.Builder(context).build().apply {
|
ExoPlayer.Builder(context).build().apply {
|
||||||
playWhenReady = true
|
playWhenReady = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap ExoPlayer to intercept next/previous UI clicks
|
// Wrap ExoPlayer to intercept next/previous UI clicks
|
||||||
val forwardingPlayer = remember(exoPlayer, uiState.movie, uiState.currentEpisode) {
|
val forwardingPlayer = remember(exoPlayer, uiState.movie, uiState.currentEpisode) {
|
||||||
object : androidx.media3.common.ForwardingPlayer(exoPlayer) {
|
object : androidx.media3.common.ForwardingPlayer(exoPlayer) {
|
||||||
override fun getAvailableCommands(): androidx.media3.common.Player.Commands {
|
override fun getAvailableCommands(): androidx.media3.common.Player.Commands {
|
||||||
return super.getAvailableCommands().buildUpon()
|
return super.getAvailableCommands().buildUpon()
|
||||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT)
|
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT)
|
||||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS)
|
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hasNextMediaItem(): Boolean {
|
override fun hasNextMediaItem(): Boolean {
|
||||||
val eps = uiState.movie?.episodes ?: return false
|
val eps = uiState.movie?.episodes ?: return false
|
||||||
if (eps.isEmpty()) return false
|
if (eps.isEmpty()) return false
|
||||||
val maxEp = eps.maxOf { it.number }
|
val maxEp = eps.maxOf { it.number }
|
||||||
return uiState.currentEpisode < maxEp
|
return uiState.currentEpisode < maxEp
|
||||||
}
|
}
|
||||||
override fun hasPreviousMediaItem(): Boolean {
|
override fun hasPreviousMediaItem(): Boolean {
|
||||||
val eps = uiState.movie?.episodes ?: return false
|
val eps = uiState.movie?.episodes ?: return false
|
||||||
if (eps.isEmpty()) return false
|
if (eps.isEmpty()) return false
|
||||||
val minEp = eps.minOf { it.number }
|
val minEp = eps.minOf { it.number }
|
||||||
return uiState.currentEpisode > minEp
|
return uiState.currentEpisode > minEp
|
||||||
}
|
}
|
||||||
override fun seekToNextMediaItem() {
|
override fun seekToNextMediaItem() {
|
||||||
if (hasNextMediaItem()) {
|
if (hasNextMediaItem()) {
|
||||||
viewModel.changeEpisode(uiState.currentEpisode + 1)
|
viewModel.changeEpisode(uiState.currentEpisode + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun seekToNext() {
|
override fun seekToNext() {
|
||||||
seekToNextMediaItem()
|
seekToNextMediaItem()
|
||||||
}
|
}
|
||||||
override fun seekToPreviousMediaItem() {
|
override fun seekToPreviousMediaItem() {
|
||||||
if (hasPreviousMediaItem()) {
|
if (hasPreviousMediaItem()) {
|
||||||
viewModel.changeEpisode(uiState.currentEpisode - 1)
|
viewModel.changeEpisode(uiState.currentEpisode - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override fun seekToPrevious() {
|
override fun seekToPrevious() {
|
||||||
seekToPreviousMediaItem()
|
seekToPreviousMediaItem()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update player when source changes
|
// Update player when source changes
|
||||||
LaunchedEffect(uiState.source) {
|
LaunchedEffect(uiState.source) {
|
||||||
uiState.source?.let { source ->
|
uiState.source?.let { source ->
|
||||||
val dataSourceFactory = DefaultDataSource.Factory(context)
|
val dataSourceFactory = DefaultDataSource.Factory(context)
|
||||||
val mediaItem = MediaItem.fromUri(source.streamUrl)
|
val mediaItem = MediaItem.fromUri(source.streamUrl)
|
||||||
|
|
||||||
android.util.Log.e("StreamFlowPlayer", "Setting media source: ${source.streamUrl}")
|
android.util.Log.e("StreamFlowPlayer", "Setting media source: ${source.streamUrl}")
|
||||||
|
|
||||||
exoPlayer.addListener(object : androidx.media3.common.Player.Listener {
|
exoPlayer.addListener(object : androidx.media3.common.Player.Listener {
|
||||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||||
android.util.Log.e("StreamFlowPlayer", "Player Error: ${error.message}", error)
|
android.util.Log.e("StreamFlowPlayer", "Player Error: ${error.message}", error)
|
||||||
}
|
}
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
android.util.Log.e("StreamFlowPlayer", "Playback State: $playbackState")
|
android.util.Log.e("StreamFlowPlayer", "Playback State: $playbackState")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (source.streamUrl.contains(".m3u8")) {
|
if (source.streamUrl.contains(".m3u8")) {
|
||||||
val hlsSource = HlsMediaSource.Factory(dataSourceFactory)
|
val hlsSource = HlsMediaSource.Factory(dataSourceFactory)
|
||||||
.createMediaSource(mediaItem)
|
.createMediaSource(mediaItem)
|
||||||
exoPlayer.setMediaSource(hlsSource)
|
exoPlayer.setMediaSource(hlsSource)
|
||||||
} else {
|
} else {
|
||||||
exoPlayer.setMediaItem(mediaItem)
|
exoPlayer.setMediaItem(mediaItem)
|
||||||
}
|
}
|
||||||
exoPlayer.prepare()
|
exoPlayer.prepare()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
exoPlayer.release()
|
exoPlayer.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
.focusable()
|
.focusable()
|
||||||
.onPreviewKeyEvent { keyEvent ->
|
.onPreviewKeyEvent { keyEvent ->
|
||||||
if (keyEvent.type == KeyEventType.KeyDown) {
|
if (keyEvent.type == KeyEventType.KeyDown) {
|
||||||
when (keyEvent.nativeKeyEvent.keyCode) {
|
when (keyEvent.nativeKeyEvent.keyCode) {
|
||||||
android.view.KeyEvent.KEYCODE_DPAD_CENTER,
|
android.view.KeyEvent.KEYCODE_DPAD_CENTER,
|
||||||
android.view.KeyEvent.KEYCODE_ENTER -> {
|
android.view.KeyEvent.KEYCODE_ENTER -> {
|
||||||
// Toggle controls visibility
|
// Toggle controls visibility
|
||||||
if (playerView?.isControllerFullyVisible == true) {
|
if (playerView?.isControllerFullyVisible == true) {
|
||||||
playerView?.hideController()
|
playerView?.hideController()
|
||||||
} else {
|
} else {
|
||||||
playerView?.showController()
|
playerView?.showController()
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
android.view.KeyEvent.KEYCODE_DPAD_LEFT -> {
|
android.view.KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||||
// Seek backward 10s
|
// Seek backward 10s
|
||||||
playerView?.showController()
|
playerView?.showController()
|
||||||
exoPlayer.seekTo(maxOf(0, exoPlayer.currentPosition - 10000))
|
exoPlayer.seekTo(maxOf(0, exoPlayer.currentPosition - 10000))
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||||
// Seek forward 10s
|
// Seek forward 10s
|
||||||
playerView?.showController()
|
playerView?.showController()
|
||||||
exoPlayer.seekTo(minOf(exoPlayer.duration, exoPlayer.currentPosition + 10000))
|
exoPlayer.seekTo(minOf(exoPlayer.duration, exoPlayer.currentPosition + 10000))
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
android.view.KeyEvent.KEYCODE_DPAD_UP,
|
android.view.KeyEvent.KEYCODE_DPAD_UP,
|
||||||
android.view.KeyEvent.KEYCODE_DPAD_DOWN -> {
|
android.view.KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||||
playerView?.showController()
|
playerView?.showController()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
android.view.KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
android.view.KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||||
if (forwardingPlayer.hasNextMediaItem()) {
|
if (forwardingPlayer.hasNextMediaItem()) {
|
||||||
forwardingPlayer.seekToNextMediaItem()
|
forwardingPlayer.seekToNextMediaItem()
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||||
if (forwardingPlayer.hasPreviousMediaItem()) {
|
if (forwardingPlayer.hasPreviousMediaItem()) {
|
||||||
forwardingPlayer.seekToPreviousMediaItem()
|
forwardingPlayer.seekToPreviousMediaItem()
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
} else false
|
} else false
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiState.isLoading || uiState.source == null) {
|
if (uiState.isLoading || uiState.source == null) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(
|
Text(
|
||||||
"Loading stream...",
|
"Loading stream...",
|
||||||
style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary)
|
style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary)
|
||||||
)
|
)
|
||||||
uiState.movie?.let { movie ->
|
uiState.movie?.let { movie ->
|
||||||
Text(
|
Text(
|
||||||
movie.title,
|
movie.title,
|
||||||
style = StreamFlowTheme.typography.bodyLarge,
|
style = StreamFlowTheme.typography.bodyLarge,
|
||||||
modifier = Modifier.padding(top = 8.dp)
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ExoPlayer View
|
// ExoPlayer View
|
||||||
android.util.Log.e("StreamFlowPlayer", "Drawing AndroidView for Player")
|
android.util.Log.e("StreamFlowPlayer", "Drawing AndroidView for Player")
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
android.util.Log.e("StreamFlowPlayer", "Creating PlayerView factory")
|
android.util.Log.e("StreamFlowPlayer", "Creating PlayerView factory")
|
||||||
PlayerView(ctx).apply {
|
PlayerView(ctx).apply {
|
||||||
player = forwardingPlayer
|
player = forwardingPlayer
|
||||||
useController = true
|
useController = true
|
||||||
setShowNextButton(true)
|
setShowNextButton(true)
|
||||||
setShowPreviousButton(true)
|
setShowPreviousButton(true)
|
||||||
controllerAutoShow = true
|
controllerAutoShow = true
|
||||||
keepScreenOn = true // Prevent screen sleep during playback
|
keepScreenOn = true // Prevent screen sleep during playback
|
||||||
layoutParams = FrameLayout.LayoutParams(
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
)
|
)
|
||||||
playerView = this
|
playerView = this
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error overlay
|
// Error overlay
|
||||||
uiState.error?.let { error ->
|
uiState.error?.let { error ->
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
error,
|
error,
|
||||||
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red)
|
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,124 +1,124 @@
|
||||||
package com.streamflow.tv.ui.screens
|
package com.streamflow.tv.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||||
import androidx.tv.foundation.lazy.grid.items
|
import androidx.tv.foundation.lazy.grid.items
|
||||||
import androidx.tv.material3.*
|
import androidx.tv.material3.*
|
||||||
import com.streamflow.tv.ui.components.MovieCard
|
import com.streamflow.tv.ui.components.MovieCard
|
||||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||||
import com.streamflow.tv.viewmodel.SearchViewModel
|
import com.streamflow.tv.viewmodel.SearchViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchScreen(
|
fun SearchScreen(
|
||||||
onMovieClick: (String) -> Unit,
|
onMovieClick: (String) -> Unit,
|
||||||
viewModel: SearchViewModel = viewModel()
|
viewModel: SearchViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val colors = StreamFlowTheme.colors
|
val colors = StreamFlowTheme.colors
|
||||||
var textValue by remember { mutableStateOf(TextFieldValue("")) }
|
var textValue by remember { mutableStateOf(TextFieldValue("")) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(colors.background)
|
.background(colors.background)
|
||||||
.padding(horizontal = 48.dp, vertical = 32.dp)
|
.padding(horizontal = 48.dp, vertical = 32.dp)
|
||||||
) {
|
) {
|
||||||
// Search bar
|
// Search bar
|
||||||
Text(
|
Text(
|
||||||
text = "Search",
|
text = "Search",
|
||||||
style = StreamFlowTheme.typography.displayMedium,
|
style = StreamFlowTheme.typography.displayMedium,
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(colors.surfaceVariant, RoundedCornerShape(12.dp))
|
.background(colors.surfaceVariant, RoundedCornerShape(12.dp))
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
) {
|
) {
|
||||||
Text("🔍 ", style = StreamFlowTheme.typography.titleMedium)
|
Text("🔍 ", style = StreamFlowTheme.typography.titleMedium)
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = textValue,
|
value = textValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
textValue = it
|
textValue = it
|
||||||
if (it.text.length >= 2) {
|
if (it.text.length >= 2) {
|
||||||
viewModel.search(it.text)
|
viewModel.search(it.text)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
textStyle = StreamFlowTheme.typography.titleMedium,
|
textStyle = StreamFlowTheme.typography.titleMedium,
|
||||||
cursorBrush = SolidColor(colors.primary),
|
cursorBrush = SolidColor(colors.primary),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
decorationBox = { innerTextField ->
|
decorationBox = { innerTextField ->
|
||||||
Box {
|
Box {
|
||||||
if (textValue.text.isEmpty()) {
|
if (textValue.text.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
"Type to search...",
|
"Type to search...",
|
||||||
style = StreamFlowTheme.typography.titleMedium.copy(
|
style = StreamFlowTheme.typography.titleMedium.copy(
|
||||||
color = Color.White.copy(alpha = 0.3f)
|
color = Color.White.copy(alpha = 0.3f)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
innerTextField()
|
innerTextField()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
// Results
|
// Results
|
||||||
when {
|
when {
|
||||||
uiState.isLoading -> {
|
uiState.isLoading -> {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text("Searching...", style = StreamFlowTheme.typography.bodyLarge.copy(color = colors.primary))
|
Text("Searching...", style = StreamFlowTheme.typography.bodyLarge.copy(color = colors.primary))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
uiState.results.isNotEmpty() -> {
|
uiState.results.isNotEmpty() -> {
|
||||||
TvLazyVerticalGrid(
|
TvLazyVerticalGrid(
|
||||||
columns = TvGridCells.Adaptive(180.dp),
|
columns = TvGridCells.Adaptive(180.dp),
|
||||||
contentPadding = PaddingValues(4.dp),
|
contentPadding = PaddingValues(4.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
items(uiState.results, key = { it.slug }) { movie ->
|
items(uiState.results, key = { it.slug }) { movie ->
|
||||||
MovieCard(
|
MovieCard(
|
||||||
movie = movie,
|
movie = movie,
|
||||||
onClick = { onMovieClick(movie.slug) }
|
onClick = { onMovieClick(movie.slug) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
uiState.hasSearched -> {
|
uiState.hasSearched -> {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text("No results found", style = StreamFlowTheme.typography.bodyLarge)
|
Text("No results found", style = StreamFlowTheme.typography.bodyLarge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text("🎬", style = StreamFlowTheme.typography.displayLarge)
|
Text("🎬", style = StreamFlowTheme.typography.displayLarge)
|
||||||
Text(
|
Text(
|
||||||
"Search for movies and shows",
|
"Search for movies and shows",
|
||||||
style = StreamFlowTheme.typography.bodyLarge,
|
style = StreamFlowTheme.typography.bodyLarge,
|
||||||
modifier = Modifier.padding(top = 12.dp)
|
modifier = Modifier.padding(top = 12.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,171 +1,171 @@
|
||||||
package com.streamflow.tv.ui.screens
|
package com.streamflow.tv.ui.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.*
|
import androidx.tv.material3.*
|
||||||
import com.streamflow.tv.data.api.ApiClient
|
import com.streamflow.tv.data.api.ApiClient
|
||||||
import com.streamflow.tv.data.repository.UserDataRepository
|
import com.streamflow.tv.data.repository.UserDataRepository
|
||||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
currentTheme: String,
|
currentTheme: String,
|
||||||
onThemeChange: (String) -> Unit
|
onThemeChange: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val colors = StreamFlowTheme.colors
|
val colors = StreamFlowTheme.colors
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val userRepo = remember { UserDataRepository(context) }
|
val userRepo = remember { UserDataRepository(context) }
|
||||||
|
|
||||||
var serverUrl by remember { mutableStateOf(TextFieldValue(ApiClient.baseUrl.removeSuffix("/"))) }
|
var serverUrl by remember { mutableStateOf(TextFieldValue(ApiClient.baseUrl.removeSuffix("/"))) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
val savedUrl = userRepo.serverUrl.first()
|
val savedUrl = userRepo.serverUrl.first()
|
||||||
serverUrl = TextFieldValue(savedUrl)
|
serverUrl = TextFieldValue(savedUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
val themes = listOf(
|
val themes = listOf(
|
||||||
Triple("default", "StreamFlow", Color(0xFF06B6D4)),
|
Triple("default", "StreamFlow", Color(0xFF06B6D4)),
|
||||||
Triple("netflix", "Netflix", Color(0xFFE50914)),
|
Triple("netflix", "Netflix", Color(0xFFE50914)),
|
||||||
Triple("apple", "Apple TV+", Color(0xFFFFFFFF))
|
Triple("apple", "Apple TV+", Color(0xFFFFFFFF))
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(colors.background)
|
.background(colors.background)
|
||||||
.padding(horizontal = 48.dp, vertical = 32.dp)
|
.padding(horizontal = 48.dp, vertical = 32.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Settings",
|
text = "Settings",
|
||||||
style = StreamFlowTheme.typography.displayMedium,
|
style = StreamFlowTheme.typography.displayMedium,
|
||||||
modifier = Modifier.padding(bottom = 32.dp)
|
modifier = Modifier.padding(bottom = 32.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "CHOOSE THEME",
|
text = "CHOOSE THEME",
|
||||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||||
color = Color.White.copy(alpha = 0.5f)
|
color = Color.White.copy(alpha = 0.5f)
|
||||||
),
|
),
|
||||||
modifier = Modifier.padding(bottom = 12.dp)
|
modifier = Modifier.padding(bottom = 12.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
themes.forEach { (id, name, color) ->
|
themes.forEach { (id, name, color) ->
|
||||||
val isSelected = currentTheme == id
|
val isSelected = currentTheme == id
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { onThemeChange(id) },
|
onClick = { onThemeChange(id) },
|
||||||
modifier = Modifier.width(200.dp),
|
modifier = Modifier.width(200.dp),
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = if (isSelected) Color.White.copy(alpha = 0.1f) else colors.surfaceVariant,
|
containerColor = if (isSelected) Color.White.copy(alpha = 0.1f) else colors.surfaceVariant,
|
||||||
focusedContainerColor = Color.White.copy(alpha = 0.15f)
|
focusedContainerColor = Color.White.copy(alpha = 0.15f)
|
||||||
),
|
),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(20.dp),
|
modifier = Modifier.padding(20.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.background(Color.Black, RoundedCornerShape(12.dp)),
|
.background(Color.Black, RoundedCornerShape(12.dp)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = name.first().toString(),
|
text = name.first().toString(),
|
||||||
style = StreamFlowTheme.typography.headlineLarge.copy(color = color)
|
style = StreamFlowTheme.typography.headlineLarge.copy(color = color)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = name,
|
text = name,
|
||||||
style = StreamFlowTheme.typography.titleMedium
|
style = StreamFlowTheme.typography.titleMedium
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Text(
|
Text(
|
||||||
text = "✓ Active",
|
text = "✓ Active",
|
||||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||||
color = Color(0xFF22C55E)
|
color = Color(0xFF22C55E)
|
||||||
),
|
),
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(40.dp))
|
Spacer(Modifier.height(40.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "SERVER URL",
|
text = "SERVER URL",
|
||||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||||
color = Color.White.copy(alpha = 0.5f)
|
color = Color.White.copy(alpha = 0.5f)
|
||||||
),
|
),
|
||||||
modifier = Modifier.padding(bottom = 12.dp)
|
modifier = Modifier.padding(bottom = 12.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = serverUrl,
|
value = serverUrl,
|
||||||
onValueChange = { serverUrl = it },
|
onValueChange = { serverUrl = it },
|
||||||
textStyle = StreamFlowTheme.typography.titleMedium,
|
textStyle = StreamFlowTheme.typography.titleMedium,
|
||||||
cursorBrush = SolidColor(colors.primary),
|
cursorBrush = SolidColor(colors.primary),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(400.dp)
|
.width(400.dp)
|
||||||
.background(colors.surfaceVariant, RoundedCornerShape(12.dp))
|
.background(colors.surfaceVariant, RoundedCornerShape(12.dp))
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
onClick = {
|
onClick = {
|
||||||
val url = serverUrl.text.trim()
|
val url = serverUrl.text.trim()
|
||||||
ApiClient.baseUrl = url
|
ApiClient.baseUrl = url
|
||||||
scope.launch { userRepo.setServerUrl(url) }
|
scope.launch { userRepo.setServerUrl(url) }
|
||||||
},
|
},
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||||
colors = ClickableSurfaceDefaults.colors(
|
colors = ClickableSurfaceDefaults.colors(
|
||||||
containerColor = colors.primary,
|
containerColor = colors.primary,
|
||||||
focusedContainerColor = colors.accent
|
focusedContainerColor = colors.accent
|
||||||
),
|
),
|
||||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"Save",
|
"Save",
|
||||||
style = StreamFlowTheme.typography.labelLarge.copy(color = Color.White),
|
style = StreamFlowTheme.typography.labelLarge.copy(color = Color.White),
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Enter the IP address and port of your StreamFlow backend server.",
|
text = "Enter the IP address and port of your StreamFlow backend server.",
|
||||||
style = StreamFlowTheme.typography.bodyMedium,
|
style = StreamFlowTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.widthIn(max = 500.dp)
|
modifier = Modifier.widthIn(max = 500.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
package com.streamflow.tv.ui.theme
|
package com.streamflow.tv.ui.theme
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
// StreamFlow Default Theme (Cyan/Blue)
|
// StreamFlow Default Theme (Cyan/Blue)
|
||||||
val StreamFlowPrimary = Color(0xFF06B6D4)
|
val StreamFlowPrimary = Color(0xFF06B6D4)
|
||||||
val StreamFlowSecondary = Color(0xFF3B82F6)
|
val StreamFlowSecondary = Color(0xFF3B82F6)
|
||||||
val StreamFlowAccent = Color(0xFF22D3EE)
|
val StreamFlowAccent = Color(0xFF22D3EE)
|
||||||
|
|
||||||
// Netflix Theme (Red)
|
// Netflix Theme (Red)
|
||||||
val NetflixPrimary = Color(0xFFE50914)
|
val NetflixPrimary = Color(0xFFE50914)
|
||||||
val NetflixSecondary = Color(0xFFB81D24)
|
val NetflixSecondary = Color(0xFFB81D24)
|
||||||
val NetflixAccent = Color(0xFFFF3D3D)
|
val NetflixAccent = Color(0xFFFF3D3D)
|
||||||
|
|
||||||
// Apple TV+ Theme (White/Silver)
|
// Apple TV+ Theme (White/Silver)
|
||||||
val ApplePrimary = Color(0xFFFFFFFF)
|
val ApplePrimary = Color(0xFFFFFFFF)
|
||||||
val AppleSecondary = Color(0xFFA1A1AA)
|
val AppleSecondary = Color(0xFFA1A1AA)
|
||||||
val AppleAccent = Color(0xFFD4D4D8)
|
val AppleAccent = Color(0xFFD4D4D8)
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
val DarkBackground = Color(0xFF141414)
|
val DarkBackground = Color(0xFF141414)
|
||||||
val DarkSurface = Color(0xFF1A1A1A)
|
val DarkSurface = Color(0xFF1A1A1A)
|
||||||
val DarkSurfaceVariant = Color(0xFF262626)
|
val DarkSurfaceVariant = Color(0xFF262626)
|
||||||
val TextPrimary = Color(0xFFFFFFFF)
|
val TextPrimary = Color(0xFFFFFFFF)
|
||||||
val TextSecondary = Color(0xFF9CA3AF)
|
val TextSecondary = Color(0xFF9CA3AF)
|
||||||
val TextMuted = Color(0xFF6B7280)
|
val TextMuted = Color(0xFF6B7280)
|
||||||
val CardBackground = Color(0xFF1E1E1E)
|
val CardBackground = Color(0xFF1E1E1E)
|
||||||
val DividerColor = Color(0x1AFFFFFF)
|
val DividerColor = Color(0x1AFFFFFF)
|
||||||
|
|
|
||||||
|
|
@ -1,122 +1,122 @@
|
||||||
package com.streamflow.tv.ui.theme
|
package com.streamflow.tv.ui.theme
|
||||||
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.tv.material3.*
|
import androidx.tv.material3.*
|
||||||
|
|
||||||
data class StreamFlowColors(
|
data class StreamFlowColors(
|
||||||
val primary: Color,
|
val primary: Color,
|
||||||
val secondary: Color,
|
val secondary: Color,
|
||||||
val accent: Color,
|
val accent: Color,
|
||||||
val background: Color = DarkBackground,
|
val background: Color = DarkBackground,
|
||||||
val surface: Color = DarkSurface,
|
val surface: Color = DarkSurface,
|
||||||
val surfaceVariant: Color = DarkSurfaceVariant,
|
val surfaceVariant: Color = DarkSurfaceVariant,
|
||||||
val textPrimary: Color = TextPrimary,
|
val textPrimary: Color = TextPrimary,
|
||||||
val textSecondary: Color = TextSecondary,
|
val textSecondary: Color = TextSecondary,
|
||||||
val card: Color = CardBackground,
|
val card: Color = CardBackground,
|
||||||
val divider: Color = DividerColor
|
val divider: Color = DividerColor
|
||||||
)
|
)
|
||||||
|
|
||||||
val LocalStreamFlowColors = staticCompositionLocalOf {
|
val LocalStreamFlowColors = staticCompositionLocalOf {
|
||||||
StreamFlowColors(
|
StreamFlowColors(
|
||||||
primary = StreamFlowPrimary,
|
primary = StreamFlowPrimary,
|
||||||
secondary = StreamFlowSecondary,
|
secondary = StreamFlowSecondary,
|
||||||
accent = StreamFlowAccent
|
accent = StreamFlowAccent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
object StreamFlowTheme {
|
object StreamFlowTheme {
|
||||||
val colors: StreamFlowColors
|
val colors: StreamFlowColors
|
||||||
@Composable
|
@Composable
|
||||||
@ReadOnlyComposable
|
@ReadOnlyComposable
|
||||||
get() = LocalStreamFlowColors.current
|
get() = LocalStreamFlowColors.current
|
||||||
|
|
||||||
val typography = AppTypography
|
val typography = AppTypography
|
||||||
}
|
}
|
||||||
|
|
||||||
fun streamFlowColors(themeName: String): StreamFlowColors {
|
fun streamFlowColors(themeName: String): StreamFlowColors {
|
||||||
return when (themeName) {
|
return when (themeName) {
|
||||||
"netflix" -> StreamFlowColors(
|
"netflix" -> StreamFlowColors(
|
||||||
primary = NetflixPrimary,
|
primary = NetflixPrimary,
|
||||||
secondary = NetflixSecondary,
|
secondary = NetflixSecondary,
|
||||||
accent = NetflixAccent
|
accent = NetflixAccent
|
||||||
)
|
)
|
||||||
"apple" -> StreamFlowColors(
|
"apple" -> StreamFlowColors(
|
||||||
primary = ApplePrimary,
|
primary = ApplePrimary,
|
||||||
secondary = AppleSecondary,
|
secondary = AppleSecondary,
|
||||||
accent = AppleAccent
|
accent = AppleAccent
|
||||||
)
|
)
|
||||||
else -> StreamFlowColors(
|
else -> StreamFlowColors(
|
||||||
primary = StreamFlowPrimary,
|
primary = StreamFlowPrimary,
|
||||||
secondary = StreamFlowSecondary,
|
secondary = StreamFlowSecondary,
|
||||||
accent = StreamFlowAccent
|
accent = StreamFlowAccent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun StreamFlowTvTheme(
|
fun StreamFlowTvTheme(
|
||||||
themeName: String = "default",
|
themeName: String = "default",
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val colors = streamFlowColors(themeName)
|
val colors = streamFlowColors(themeName)
|
||||||
|
|
||||||
val colorScheme = ColorScheme(
|
val colorScheme = ColorScheme(
|
||||||
primary = colors.primary,
|
primary = colors.primary,
|
||||||
onPrimary = Color.White,
|
onPrimary = Color.White,
|
||||||
primaryContainer = colors.primary.copy(alpha = 0.3f),
|
primaryContainer = colors.primary.copy(alpha = 0.3f),
|
||||||
onPrimaryContainer = Color.White,
|
onPrimaryContainer = Color.White,
|
||||||
secondary = colors.secondary,
|
secondary = colors.secondary,
|
||||||
onSecondary = Color.White,
|
onSecondary = Color.White,
|
||||||
secondaryContainer = colors.secondary.copy(alpha = 0.3f),
|
secondaryContainer = colors.secondary.copy(alpha = 0.3f),
|
||||||
onSecondaryContainer = Color.White,
|
onSecondaryContainer = Color.White,
|
||||||
tertiary = colors.accent,
|
tertiary = colors.accent,
|
||||||
onTertiary = Color.Black,
|
onTertiary = Color.Black,
|
||||||
tertiaryContainer = colors.accent.copy(alpha = 0.3f),
|
tertiaryContainer = colors.accent.copy(alpha = 0.3f),
|
||||||
onTertiaryContainer = Color.White,
|
onTertiaryContainer = Color.White,
|
||||||
background = colors.background,
|
background = colors.background,
|
||||||
onBackground = Color.White,
|
onBackground = Color.White,
|
||||||
surface = colors.surface,
|
surface = colors.surface,
|
||||||
onSurface = Color.White,
|
onSurface = Color.White,
|
||||||
surfaceVariant = colors.surfaceVariant,
|
surfaceVariant = colors.surfaceVariant,
|
||||||
onSurfaceVariant = Color.White,
|
onSurfaceVariant = Color.White,
|
||||||
error = Color.Red,
|
error = Color.Red,
|
||||||
onError = Color.White,
|
onError = Color.White,
|
||||||
errorContainer = Color.Red.copy(alpha = 0.1f),
|
errorContainer = Color.Red.copy(alpha = 0.1f),
|
||||||
onErrorContainer = Color.Red,
|
onErrorContainer = Color.Red,
|
||||||
border = colors.divider,
|
border = colors.divider,
|
||||||
borderVariant = colors.divider,
|
borderVariant = colors.divider,
|
||||||
scrim = Color.Black,
|
scrim = Color.Black,
|
||||||
inverseSurface = Color.White,
|
inverseSurface = Color.White,
|
||||||
inverseOnSurface = Color.Black,
|
inverseOnSurface = Color.Black,
|
||||||
inversePrimary = colors.primary,
|
inversePrimary = colors.primary,
|
||||||
surfaceTint = colors.primary
|
surfaceTint = colors.primary
|
||||||
)
|
)
|
||||||
|
|
||||||
val tvTypography = Typography(
|
val tvTypography = Typography(
|
||||||
displayLarge = AppTypography.displayLarge,
|
displayLarge = AppTypography.displayLarge,
|
||||||
displayMedium = AppTypography.displayMedium,
|
displayMedium = AppTypography.displayMedium,
|
||||||
displaySmall = AppTypography.displayMedium,
|
displaySmall = AppTypography.displayMedium,
|
||||||
headlineLarge = AppTypography.headlineLarge,
|
headlineLarge = AppTypography.headlineLarge,
|
||||||
headlineMedium = AppTypography.headlineMedium,
|
headlineMedium = AppTypography.headlineMedium,
|
||||||
headlineSmall = AppTypography.headlineMedium,
|
headlineSmall = AppTypography.headlineMedium,
|
||||||
titleLarge = AppTypography.titleLarge,
|
titleLarge = AppTypography.titleLarge,
|
||||||
titleMedium = AppTypography.titleMedium,
|
titleMedium = AppTypography.titleMedium,
|
||||||
titleSmall = AppTypography.titleMedium,
|
titleSmall = AppTypography.titleMedium,
|
||||||
bodyLarge = AppTypography.bodyLarge,
|
bodyLarge = AppTypography.bodyLarge,
|
||||||
bodyMedium = AppTypography.bodyMedium,
|
bodyMedium = AppTypography.bodyMedium,
|
||||||
bodySmall = AppTypography.bodyMedium,
|
bodySmall = AppTypography.bodyMedium,
|
||||||
labelLarge = AppTypography.labelLarge,
|
labelLarge = AppTypography.labelLarge,
|
||||||
labelMedium = AppTypography.labelLarge,
|
labelMedium = AppTypography.labelLarge,
|
||||||
labelSmall = AppTypography.labelSmall
|
labelSmall = AppTypography.labelSmall
|
||||||
)
|
)
|
||||||
|
|
||||||
CompositionLocalProvider(LocalStreamFlowColors provides colors) {
|
CompositionLocalProvider(LocalStreamFlowColors provides colors) {
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = tvTypography,
|
typography = tvTypography,
|
||||||
content = content
|
content = content
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,59 @@
|
||||||
package com.streamflow.tv.ui.theme
|
package com.streamflow.tv.ui.theme
|
||||||
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
object AppTypography {
|
object AppTypography {
|
||||||
val displayLarge = TextStyle(
|
val displayLarge = TextStyle(
|
||||||
fontSize = 36.sp,
|
fontSize = 36.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = TextPrimary,
|
color = TextPrimary,
|
||||||
letterSpacing = (-0.5).sp
|
letterSpacing = (-0.5).sp
|
||||||
)
|
)
|
||||||
val displayMedium = TextStyle(
|
val displayMedium = TextStyle(
|
||||||
fontSize = 28.sp,
|
fontSize = 28.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = TextPrimary
|
color = TextPrimary
|
||||||
)
|
)
|
||||||
val headlineLarge = TextStyle(
|
val headlineLarge = TextStyle(
|
||||||
fontSize = 24.sp,
|
fontSize = 24.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = TextPrimary
|
color = TextPrimary
|
||||||
)
|
)
|
||||||
val headlineMedium = TextStyle(
|
val headlineMedium = TextStyle(
|
||||||
fontSize = 20.sp,
|
fontSize = 20.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = TextPrimary
|
color = TextPrimary
|
||||||
)
|
)
|
||||||
val titleLarge = TextStyle(
|
val titleLarge = TextStyle(
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = TextPrimary
|
color = TextPrimary
|
||||||
)
|
)
|
||||||
val titleMedium = TextStyle(
|
val titleMedium = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = TextPrimary
|
color = TextPrimary
|
||||||
)
|
)
|
||||||
val bodyLarge = TextStyle(
|
val bodyLarge = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
color = TextSecondary
|
color = TextSecondary
|
||||||
)
|
)
|
||||||
val bodyMedium = TextStyle(
|
val bodyMedium = TextStyle(
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
color = TextSecondary
|
color = TextSecondary
|
||||||
)
|
)
|
||||||
val labelLarge = TextStyle(
|
val labelLarge = TextStyle(
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = TextPrimary
|
color = TextPrimary
|
||||||
)
|
)
|
||||||
val labelSmall = TextStyle(
|
val labelSmall = TextStyle(
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = TextMuted
|
color = TextMuted
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,45 @@
|
||||||
package com.streamflow.tv.viewmodel
|
package com.streamflow.tv.viewmodel
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.streamflow.tv.data.model.MovieDetail
|
import com.streamflow.tv.data.model.MovieDetail
|
||||||
import com.streamflow.tv.data.repository.MovieRepository
|
import com.streamflow.tv.data.repository.MovieRepository
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class DetailUiState(
|
data class DetailUiState(
|
||||||
val movie: MovieDetail? = null,
|
val movie: MovieDetail? = null,
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val isInMyList: Boolean = false
|
val isInMyList: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
class DetailViewModel : ViewModel() {
|
class DetailViewModel : ViewModel() {
|
||||||
|
|
||||||
private val repository = MovieRepository()
|
private val repository = MovieRepository()
|
||||||
private val _uiState = MutableStateFlow(DetailUiState())
|
private val _uiState = MutableStateFlow(DetailUiState())
|
||||||
val uiState: StateFlow<DetailUiState> = _uiState
|
val uiState: StateFlow<DetailUiState> = _uiState
|
||||||
|
|
||||||
fun loadMovie(slug: String) {
|
fun loadMovie(slug: String) {
|
||||||
android.util.Log.e("DetailVM", "loadMovie($slug) called")
|
android.util.Log.e("DetailVM", "loadMovie($slug) called")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = DetailUiState(isLoading = true)
|
_uiState.value = DetailUiState(isLoading = true)
|
||||||
try {
|
try {
|
||||||
val movie = repository.getMovieDetail(slug)
|
val movie = repository.getMovieDetail(slug)
|
||||||
android.util.Log.e("DetailVM", "loadMovie success: ${movie.title}, episodes: ${movie.episodes?.size}")
|
android.util.Log.e("DetailVM", "loadMovie success: ${movie.title}, episodes: ${movie.episodes?.size}")
|
||||||
_uiState.value = DetailUiState(movie = movie, isLoading = false)
|
_uiState.value = DetailUiState(movie = movie, isLoading = false)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("DetailVM", "loadMovie failed", e)
|
android.util.Log.e("DetailVM", "loadMovie failed", e)
|
||||||
_uiState.value = DetailUiState(
|
_uiState.value = DetailUiState(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = e.message ?: "Failed to load movie details"
|
error = e.message ?: "Failed to load movie details"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleMyList(isInList: Boolean) {
|
fun toggleMyList(isInList: Boolean) {
|
||||||
_uiState.value = _uiState.value.copy(isInMyList = !isInList)
|
_uiState.value = _uiState.value.copy(isInMyList = !isInList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,144 +1,110 @@
|
||||||
package com.streamflow.tv.viewmodel
|
package com.streamflow.tv.viewmodel
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.streamflow.tv.data.model.Movie
|
import com.streamflow.tv.data.model.Movie
|
||||||
import com.streamflow.tv.data.repository.MovieRepository
|
import com.streamflow.tv.data.repository.MovieRepository
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class HomeUiState(
|
data class HomeUiState(
|
||||||
val heroMovies: List<Movie> = emptyList(),
|
val heroMovies: List<Movie> = emptyList(),
|
||||||
val watchedMovies: List<Movie> = emptyList(),
|
val watchedMovies: List<Movie> = emptyList(),
|
||||||
val recommendedMovies: List<Movie> = emptyList(),
|
val recommendedMovies: List<Movie> = emptyList(),
|
||||||
val categoryMovies: Map<String, List<Movie>> = emptyMap(),
|
val categoryMovies: Map<String, List<Movie>> = emptyMap(),
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val currentCategory: String? = null
|
val currentCategory: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
class HomeViewModel : ViewModel() {
|
class HomeViewModel : ViewModel() {
|
||||||
|
|
||||||
private val repository = MovieRepository()
|
private val repository = MovieRepository()
|
||||||
private val _uiState = MutableStateFlow(HomeUiState())
|
private val _uiState = MutableStateFlow(HomeUiState())
|
||||||
val uiState: StateFlow<HomeUiState> = _uiState
|
val uiState: StateFlow<HomeUiState> = _uiState
|
||||||
|
|
||||||
private var userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null
|
private var userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null
|
||||||
|
|
||||||
private val categories = listOf(
|
private val categories = listOf(
|
||||||
"phim-le" to "Phim Lẻ",
|
"phim-le" to "Phim Lẻ",
|
||||||
"phim-bo" to "Phim Bộ",
|
"phim-bo" to "Phim Bộ",
|
||||||
"hoat-hinh" to "Hoạt Hình",
|
"hoat-hinh" to "Hoạt Hình",
|
||||||
"tv-shows" to "TV Shows"
|
"tv-shows" to "TV Shows"
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadHome()
|
loadHome()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadHome(
|
fun loadHome(
|
||||||
category: String? = null,
|
category: String? = null,
|
||||||
userRepo: com.streamflow.tv.data.repository.UserDataRepository? = null
|
userRepo: com.streamflow.tv.data.repository.UserDataRepository? = null
|
||||||
) {
|
) {
|
||||||
if (userRepo != null) {
|
if (userRepo != null) {
|
||||||
this.userDataRepository = userRepo
|
this.userDataRepository = userRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null, currentCategory = category)
|
_uiState.value = _uiState.value.copy(isLoading = true, error = null, currentCategory = category)
|
||||||
try {
|
try {
|
||||||
// Load history if repository is available
|
// Load history if repository is available
|
||||||
val history = userRepo?.watchHistory?.first() ?: emptyList()
|
val history = userRepo?.watchHistory?.first() ?: emptyList()
|
||||||
|
|
||||||
if (category != null) {
|
if (category != null) {
|
||||||
// Load single category
|
// Load single category
|
||||||
val response = repository.getHomeVideos(category)
|
val response = repository.getHomeVideos(category)
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
heroMovies = response.items.take(5),
|
heroMovies = response.items.take(5),
|
||||||
watchedMovies = history,
|
watchedMovies = history,
|
||||||
recommendedMovies = response.items.filter { m -> history.none { it.slug == m.slug } }.shuffled().take(10),
|
recommendedMovies = response.items.filter { m -> history.none { it.slug == m.slug } }.shuffled().take(10),
|
||||||
categoryMovies = mapOf(
|
categoryMovies = mapOf(
|
||||||
categories.find { it.first == category }?.second.orEmpty() to response.items
|
categories.find { it.first == category }?.second.orEmpty() to response.items
|
||||||
),
|
),
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Load all categories for home
|
// Load all categories for home
|
||||||
val allMovies = java.util.Collections.synchronizedMap(mutableMapOf<String, List<Movie>>())
|
val allMovies = java.util.Collections.synchronizedMap(mutableMapOf<String, List<Movie>>())
|
||||||
val allFlattened = java.util.Collections.synchronizedList(mutableListOf<Movie>())
|
val allFlattened = java.util.Collections.synchronizedList(mutableListOf<Movie>())
|
||||||
|
|
||||||
kotlinx.coroutines.coroutineScope {
|
kotlinx.coroutines.coroutineScope {
|
||||||
// 1. Initial categories
|
// Load main categories only (to avoid OOM on TV devices)
|
||||||
val categoryTasks = categories.map { (slug, name) ->
|
val categoryTasks = categories.map { (slug, name) ->
|
||||||
async {
|
async {
|
||||||
try {
|
try {
|
||||||
val response = repository.getHomeVideos(slug)
|
val response = repository.getHomeVideos(slug)
|
||||||
allMovies[name] = response.items
|
allMovies[name] = response.items.take(15)
|
||||||
allFlattened.addAll(response.items)
|
allFlattened.addAll(response.items.take(15))
|
||||||
response.items
|
response.items
|
||||||
} catch (_: Exception) { emptyList<Movie>() }
|
} catch (_: Exception) { emptyList<Movie>() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch Genres & Countries metadata in parallel
|
// Wait for categories
|
||||||
val genresDeferred = async { try { repository.getGenres().take(8) } catch (_: Exception) { emptyList() } }
|
categoryTasks.awaitAll()
|
||||||
val countriesDeferred = async { try { repository.getCountries().take(5) } catch (_: Exception) { emptyList() } }
|
}
|
||||||
|
|
||||||
val genres = genresDeferred.await()
|
val heroItems = allMovies[categories.first().second]?.take(5) ?: emptyList()
|
||||||
val countries = countriesDeferred.await()
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
// 3. Fetch Genre and Country content in parallel
|
heroMovies = heroItems,
|
||||||
val genreTasks = genres.map { genre ->
|
watchedMovies = history,
|
||||||
async {
|
recommendedMovies = allFlattened.filter { m -> history.none { it.slug == m.slug } }
|
||||||
try {
|
.distinctBy { it.slug }.shuffled().take(15),
|
||||||
val response = repository.getHomeVideos(genre.slug)
|
categoryMovies = allMovies.toMap(),
|
||||||
if (response.items.isNotEmpty()) {
|
isLoading = false
|
||||||
allMovies["Genre: ${genre.name}"] = response.items
|
)
|
||||||
allFlattened.addAll(response.items)
|
}
|
||||||
}
|
} catch (e: Exception) {
|
||||||
} catch (_: Exception) { }
|
_uiState.value = _uiState.value.copy(
|
||||||
}
|
isLoading = false,
|
||||||
}
|
error = e.message ?: "Failed to load content"
|
||||||
|
)
|
||||||
val countryTasks = countries.map { country ->
|
}
|
||||||
async {
|
}
|
||||||
try {
|
}
|
||||||
val response = repository.getHomeVideos(country.slug)
|
}
|
||||||
if (response.items.isNotEmpty()) {
|
|
||||||
allMovies["Country: ${country.name}"] = response.items
|
|
||||||
allFlattened.addAll(response.items)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for everything
|
|
||||||
categoryTasks.awaitAll()
|
|
||||||
genreTasks.awaitAll()
|
|
||||||
countryTasks.awaitAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
val heroItems = allMovies[categories.first().second]?.take(5) ?: emptyList()
|
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
heroMovies = heroItems,
|
|
||||||
watchedMovies = history,
|
|
||||||
recommendedMovies = allFlattened.filter { m -> history.none { it.slug == m.slug } }
|
|
||||||
.distinctBy { it.slug }.shuffled().take(15),
|
|
||||||
categoryMovies = allMovies.toMap(),
|
|
||||||
isLoading = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
isLoading = false,
|
|
||||||
error = e.message ?: "Failed to load content"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,48 @@
|
||||||
package com.streamflow.tv.viewmodel
|
package com.streamflow.tv.viewmodel
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.streamflow.tv.data.model.Movie
|
import com.streamflow.tv.data.model.Movie
|
||||||
import com.streamflow.tv.data.repository.UserDataRepository
|
import com.streamflow.tv.data.repository.UserDataRepository
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class MyListUiState(
|
data class MyListUiState(
|
||||||
val savedMovies: List<Movie> = emptyList(),
|
val savedMovies: List<Movie> = emptyList(),
|
||||||
val watchHistory: List<Movie> = emptyList()
|
val watchHistory: List<Movie> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
class MyListViewModel(application: Application) : AndroidViewModel(application) {
|
class MyListViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val userRepo = UserDataRepository(application)
|
private val userRepo = UserDataRepository(application)
|
||||||
private val _uiState = MutableStateFlow(MyListUiState())
|
private val _uiState = MutableStateFlow(MyListUiState())
|
||||||
val uiState: StateFlow<MyListUiState> = _uiState
|
val uiState: StateFlow<MyListUiState> = _uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
userRepo.myList.collectLatest { list ->
|
userRepo.myList.collectLatest { list ->
|
||||||
_uiState.value = _uiState.value.copy(savedMovies = list)
|
_uiState.value = _uiState.value.copy(savedMovies = list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
userRepo.watchHistory.collectLatest { history ->
|
userRepo.watchHistory.collectLatest { history ->
|
||||||
_uiState.value = _uiState.value.copy(watchHistory = history)
|
_uiState.value = _uiState.value.copy(watchHistory = history)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addToMyList(movie: Movie) {
|
fun addToMyList(movie: Movie) {
|
||||||
viewModelScope.launch { userRepo.addToMyList(movie) }
|
viewModelScope.launch { userRepo.addToMyList(movie) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeFromMyList(slug: String) {
|
fun removeFromMyList(slug: String) {
|
||||||
viewModelScope.launch { userRepo.removeFromMyList(slug) }
|
viewModelScope.launch { userRepo.removeFromMyList(slug) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addToHistory(movie: Movie) {
|
fun addToHistory(movie: Movie) {
|
||||||
viewModelScope.launch { userRepo.addToHistory(movie) }
|
viewModelScope.launch { userRepo.addToHistory(movie) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,100 +1,100 @@
|
||||||
package com.streamflow.tv.viewmodel
|
package com.streamflow.tv.viewmodel
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.streamflow.tv.data.model.MovieDetail
|
import com.streamflow.tv.data.model.MovieDetail
|
||||||
import com.streamflow.tv.data.model.VideoSource
|
import com.streamflow.tv.data.model.VideoSource
|
||||||
import com.streamflow.tv.data.repository.MovieRepository
|
import com.streamflow.tv.data.repository.MovieRepository
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class PlayerUiState(
|
data class PlayerUiState(
|
||||||
val movie: MovieDetail? = null,
|
val movie: MovieDetail? = null,
|
||||||
val source: VideoSource? = null,
|
val source: VideoSource? = null,
|
||||||
val currentEpisode: Int = 1,
|
val currentEpisode: Int = 1,
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val error: String? = null
|
val error: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
class PlayerViewModel : ViewModel() {
|
class PlayerViewModel : ViewModel() {
|
||||||
|
|
||||||
private val repository = MovieRepository()
|
private val repository = MovieRepository()
|
||||||
private val _uiState = MutableStateFlow(PlayerUiState())
|
private val _uiState = MutableStateFlow(PlayerUiState())
|
||||||
val uiState: StateFlow<PlayerUiState> = _uiState
|
val uiState: StateFlow<PlayerUiState> = _uiState
|
||||||
|
|
||||||
fun loadPlayer(slug: String, episode: Int = 1) {
|
fun loadPlayer(slug: String, episode: Int = 1) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = PlayerUiState(isLoading = true, currentEpisode = episode)
|
_uiState.value = PlayerUiState(isLoading = true, currentEpisode = episode)
|
||||||
try {
|
try {
|
||||||
val movie = repository.getMovieDetail(slug)
|
val movie = repository.getMovieDetail(slug)
|
||||||
_uiState.value = _uiState.value.copy(movie = movie)
|
_uiState.value = _uiState.value.copy(movie = movie)
|
||||||
loadStream(movie, episode)
|
loadStream(movie, episode)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = e.message ?: "Failed to load"
|
error = e.message ?: "Failed to load"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun changeEpisode(episode: Int) {
|
fun changeEpisode(episode: Int) {
|
||||||
val movie = _uiState.value.movie ?: return
|
val movie = _uiState.value.movie ?: return
|
||||||
_uiState.value = _uiState.value.copy(currentEpisode = episode, isLoading = true, source = null)
|
_uiState.value = _uiState.value.copy(currentEpisode = episode, isLoading = true, source = null)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
loadStream(movie, episode)
|
loadStream(movie, episode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveToHistory(userDataRepository: com.streamflow.tv.data.repository.UserDataRepository) {
|
fun saveToHistory(userDataRepository: com.streamflow.tv.data.repository.UserDataRepository) {
|
||||||
val movie = _uiState.value.movie ?: return
|
val movie = _uiState.value.movie ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
userDataRepository.addToHistory(movie.toMovie())
|
userDataRepository.addToHistory(movie.toMovie())
|
||||||
android.util.Log.e("PlayerViewModel", "Movie saved to history: ${movie.title}")
|
android.util.Log.e("PlayerViewModel", "Movie saved to history: ${movie.title}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadStream(movie: MovieDetail, episode: Int) {
|
private suspend fun loadStream(movie: MovieDetail, episode: Int) {
|
||||||
try {
|
try {
|
||||||
val ep = movie.episodes?.find { it.number == episode }
|
val ep = movie.episodes?.find { it.number == episode }
|
||||||
android.util.Log.e("PlayerViewModel", "Loading stream for slug=${movie.slug} episode=$episode. Episode data: $ep")
|
android.util.Log.e("PlayerViewModel", "Loading stream for slug=${movie.slug} episode=$episode. Episode data: $ep")
|
||||||
|
|
||||||
if (ep != null && (ep.url.contains(".m3u8") || ep.url.contains("index.m3u8"))) {
|
if (ep != null && (ep.url.contains(".m3u8") || ep.url.contains("index.m3u8"))) {
|
||||||
// Direct HLS URL
|
// Direct HLS URL
|
||||||
android.util.Log.e("PlayerViewModel", "Direct HLS URL found: ${ep.url}")
|
android.util.Log.e("PlayerViewModel", "Direct HLS URL found: ${ep.url}")
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
source = VideoSource(
|
source = VideoSource(
|
||||||
streamUrl = ep.url,
|
streamUrl = ep.url,
|
||||||
resolution = "HD",
|
resolution = "HD",
|
||||||
formatId = "hls"
|
formatId = "hls"
|
||||||
),
|
),
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
} else if (ep != null && ep.url.isNotEmpty()) {
|
} else if (ep != null && ep.url.isNotEmpty()) {
|
||||||
// Non-HLS URL — try to extract via backend
|
// Non-HLS URL — try to extract via backend
|
||||||
android.util.Log.e("PlayerViewModel", "Extracting from URL: ${ep.url}")
|
android.util.Log.e("PlayerViewModel", "Extracting from URL: ${ep.url}")
|
||||||
val source = repository.extractVideo(ep.url)
|
val source = repository.extractVideo(ep.url)
|
||||||
android.util.Log.e("PlayerViewModel", "Extraction successful: $source")
|
android.util.Log.e("PlayerViewModel", "Extraction successful: $source")
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
source = source,
|
source = source,
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// No valid episode URL found
|
// No valid episode URL found
|
||||||
android.util.Log.e("PlayerViewModel", "No stream URL found for episode $episode")
|
android.util.Log.e("PlayerViewModel", "No stream URL found for episode $episode")
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = "No stream available for episode $episode"
|
error = "No stream available for episode $episode"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("PlayerViewModel", "Error loading stream", e)
|
android.util.Log.e("PlayerViewModel", "Error loading stream", e)
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = e.message ?: "Failed to extract stream"
|
error = e.message ?: "Failed to extract stream"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,39 @@
|
||||||
package com.streamflow.tv.viewmodel
|
package com.streamflow.tv.viewmodel
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.streamflow.tv.data.model.Movie
|
import com.streamflow.tv.data.model.Movie
|
||||||
import com.streamflow.tv.data.repository.MovieRepository
|
import com.streamflow.tv.data.repository.MovieRepository
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class SearchUiState(
|
data class SearchUiState(
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
val results: List<Movie> = emptyList(),
|
val results: List<Movie> = emptyList(),
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val hasSearched: Boolean = false
|
val hasSearched: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
class SearchViewModel : ViewModel() {
|
class SearchViewModel : ViewModel() {
|
||||||
|
|
||||||
private val repository = MovieRepository()
|
private val repository = MovieRepository()
|
||||||
private val _uiState = MutableStateFlow(SearchUiState())
|
private val _uiState = MutableStateFlow(SearchUiState())
|
||||||
val uiState: StateFlow<SearchUiState> = _uiState
|
val uiState: StateFlow<SearchUiState> = _uiState
|
||||||
|
|
||||||
fun search(query: String) {
|
fun search(query: String) {
|
||||||
if (query.isBlank()) return
|
if (query.isBlank()) return
|
||||||
_uiState.value = SearchUiState(query = query, isLoading = true, hasSearched = true)
|
_uiState.value = SearchUiState(query = query, isLoading = true, hasSearched = true)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val response = repository.searchVideos(query)
|
val response = repository.searchVideos(query)
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
results = response.items,
|
results = response.items,
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,33 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="320dp"
|
android:width="320dp"
|
||||||
android:height="180dp"
|
android:height="180dp"
|
||||||
android:viewportWidth="320"
|
android:viewportWidth="320"
|
||||||
android:viewportHeight="180">
|
android:viewportHeight="180">
|
||||||
|
|
||||||
<!-- Background -->
|
<!-- Background -->
|
||||||
<path
|
<path
|
||||||
android:pathData="M0,0h320v180H0z"
|
android:pathData="M0,0h320v180H0z"
|
||||||
android:fillColor="#141414"/>
|
android:fillColor="#141414"/>
|
||||||
|
|
||||||
<!-- Gradient accent bar -->
|
<!-- Gradient accent bar -->
|
||||||
<path
|
<path
|
||||||
android:pathData="M0,160h320v20H0z"
|
android:pathData="M0,160h320v20H0z"
|
||||||
android:fillColor="#06B6D4"/>
|
android:fillColor="#06B6D4"/>
|
||||||
|
|
||||||
<!-- Icon circle -->
|
<!-- Icon circle -->
|
||||||
<path
|
<path
|
||||||
android:pathData="M160,75m-30,0a30,30 0,1 1,60 0a30,30 0,1 1,-60 0"
|
android:pathData="M160,75m-30,0a30,30 0,1 1,60 0a30,30 0,1 1,-60 0"
|
||||||
android:fillColor="#06B6D4"/>
|
android:fillColor="#06B6D4"/>
|
||||||
|
|
||||||
<!-- Play triangle -->
|
<!-- Play triangle -->
|
||||||
<path
|
<path
|
||||||
android:pathData="M152,60L172,75L152,90z"
|
android:pathData="M152,60L172,75L152,90z"
|
||||||
android:fillColor="#FFFFFF"/>
|
android:fillColor="#FFFFFF"/>
|
||||||
|
|
||||||
<!-- Text: StreamFlow -->
|
<!-- Text: StreamFlow -->
|
||||||
<path
|
<path
|
||||||
android:pathData="M95,130h130"
|
android:pathData="M95,130h130"
|
||||||
android:strokeColor="#FFFFFF"
|
android:strokeColor="#FFFFFF"
|
||||||
android:strokeWidth="0.5"/>
|
android:strokeWidth="0.5"/>
|
||||||
</vector>
|
</vector>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="48dp"
|
android:width="48dp"
|
||||||
android:height="48dp"
|
android:height="48dp"
|
||||||
android:viewportWidth="48"
|
android:viewportWidth="48"
|
||||||
android:viewportHeight="48">
|
android:viewportHeight="48">
|
||||||
|
|
||||||
<!-- Background rounded rect -->
|
<!-- Background rounded rect -->
|
||||||
<path
|
<path
|
||||||
android:pathData="M8,0h32a8,8 0,0 1,8 8v32a8,8 0,0 1,-8 8H8A8,8 0,0 1,0 40V8A8,8 0,0 1,8 0z"
|
android:pathData="M8,0h32a8,8 0,0 1,8 8v32a8,8 0,0 1,-8 8H8A8,8 0,0 1,0 40V8A8,8 0,0 1,8 0z"
|
||||||
android:fillColor="#06B6D4"/>
|
android:fillColor="#06B6D4"/>
|
||||||
|
|
||||||
<!-- Play triangle -->
|
<!-- Play triangle -->
|
||||||
<path
|
<path
|
||||||
android:pathData="M18,12L36,24L18,36z"
|
android:pathData="M18,12L36,24L18,36z"
|
||||||
android:fillColor="#FFFFFF"/>
|
android:fillColor="#FFFFFF"/>
|
||||||
</vector>
|
</vector>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">StreamFlow</string>
|
<string name="app_name">StreamFlow</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<style name="Theme.StreamFlowTV" parent="@style/Theme.Leanback">
|
<style name="Theme.StreamFlowTV" parent="@style/Theme.Leanback">
|
||||||
<item name="android:windowIsTranslucent">true</item>
|
<item name="android:windowIsTranslucent">true</item>
|
||||||
<item name="android:windowBackground">@android:color/transparent</item>
|
<item name="android:windowBackground">@android:color/transparent</item>
|
||||||
<item name="android:backgroundDimEnabled">false</item>
|
<item name="android:backgroundDimEnabled">false</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "8.2.2" apply false
|
id("com.android.application") version "8.2.2" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,35 @@
|
||||||
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
||||||
> Task :app:preBuild UP-TO-DATE
|
> Task :app:preBuild UP-TO-DATE
|
||||||
> Task :app:preDebugBuild UP-TO-DATE
|
> Task :app:preDebugBuild UP-TO-DATE
|
||||||
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
||||||
> Task :app:generateDebugResValues UP-TO-DATE
|
> Task :app:generateDebugResValues UP-TO-DATE
|
||||||
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
||||||
> Task :app:generateDebugResources UP-TO-DATE
|
> Task :app:generateDebugResources UP-TO-DATE
|
||||||
> Task :app:mergeDebugResources UP-TO-DATE
|
> Task :app:mergeDebugResources UP-TO-DATE
|
||||||
> Task :app:packageDebugResources UP-TO-DATE
|
> Task :app:packageDebugResources UP-TO-DATE
|
||||||
> Task :app:parseDebugLocalResources UP-TO-DATE
|
> Task :app:parseDebugLocalResources UP-TO-DATE
|
||||||
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
||||||
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
||||||
> Task :app:processDebugMainManifest UP-TO-DATE
|
> Task :app:processDebugMainManifest UP-TO-DATE
|
||||||
> Task :app:processDebugManifest UP-TO-DATE
|
> Task :app:processDebugManifest UP-TO-DATE
|
||||||
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
||||||
> Task :app:processDebugResources UP-TO-DATE
|
> Task :app:processDebugResources UP-TO-DATE
|
||||||
|
|
||||||
> Task :app:compileDebugKotlin FAILED
|
> Task :app:compileDebugKotlin FAILED
|
||||||
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:120:56 Unresolved reference: accent
|
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:120:56 Unresolved reference: accent
|
||||||
|
|
||||||
FAILURE: Build failed with an exception.
|
FAILURE: Build failed with an exception.
|
||||||
|
|
||||||
* What went wrong:
|
* What went wrong:
|
||||||
Execution failed for task ':app:compileDebugKotlin'.
|
Execution failed for task ':app:compileDebugKotlin'.
|
||||||
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
||||||
> Compilation error. See log for more details
|
> Compilation error. See log for more details
|
||||||
|
|
||||||
* Try:
|
* Try:
|
||||||
> Run with --stacktrace option to get the stack trace.
|
> Run with --stacktrace option to get the stack trace.
|
||||||
> Run with --info or --debug option to get more log output.
|
> Run with --info or --debug option to get more log output.
|
||||||
> Run with --scan to get full insights.
|
> Run with --scan to get full insights.
|
||||||
> Get more help at https://help.gradle.org.
|
> Get more help at https://help.gradle.org.
|
||||||
|
|
||||||
BUILD FAILED in 3s
|
BUILD FAILED in 3s
|
||||||
14 actionable tasks: 2 executed, 12 up-to-date
|
14 actionable tasks: 2 executed, 12 up-to-date
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,35 @@
|
||||||
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
||||||
> Task :app:preBuild UP-TO-DATE
|
> Task :app:preBuild UP-TO-DATE
|
||||||
> Task :app:preDebugBuild UP-TO-DATE
|
> Task :app:preDebugBuild UP-TO-DATE
|
||||||
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
||||||
> Task :app:generateDebugResValues UP-TO-DATE
|
> Task :app:generateDebugResValues UP-TO-DATE
|
||||||
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
||||||
> Task :app:generateDebugResources UP-TO-DATE
|
> Task :app:generateDebugResources UP-TO-DATE
|
||||||
> Task :app:mergeDebugResources UP-TO-DATE
|
> Task :app:mergeDebugResources UP-TO-DATE
|
||||||
> Task :app:packageDebugResources UP-TO-DATE
|
> Task :app:packageDebugResources UP-TO-DATE
|
||||||
> Task :app:parseDebugLocalResources UP-TO-DATE
|
> Task :app:parseDebugLocalResources UP-TO-DATE
|
||||||
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
||||||
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
||||||
> Task :app:processDebugMainManifest UP-TO-DATE
|
> Task :app:processDebugMainManifest UP-TO-DATE
|
||||||
> Task :app:processDebugManifest UP-TO-DATE
|
> Task :app:processDebugManifest UP-TO-DATE
|
||||||
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
||||||
> Task :app:processDebugResources UP-TO-DATE
|
> Task :app:processDebugResources UP-TO-DATE
|
||||||
|
|
||||||
> Task :app:compileDebugKotlin FAILED
|
> Task :app:compileDebugKotlin FAILED
|
||||||
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:114:56 Unresolved reference: accent
|
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:114:56 Unresolved reference: accent
|
||||||
|
|
||||||
FAILURE: Build failed with an exception.
|
FAILURE: Build failed with an exception.
|
||||||
|
|
||||||
* What went wrong:
|
* What went wrong:
|
||||||
Execution failed for task ':app:compileDebugKotlin'.
|
Execution failed for task ':app:compileDebugKotlin'.
|
||||||
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
||||||
> Compilation error. See log for more details
|
> Compilation error. See log for more details
|
||||||
|
|
||||||
* Try:
|
* Try:
|
||||||
> Run with --stacktrace option to get the stack trace.
|
> Run with --stacktrace option to get the stack trace.
|
||||||
> Run with --info or --debug option to get more log output.
|
> Run with --info or --debug option to get more log output.
|
||||||
> Run with --scan to get full insights.
|
> Run with --scan to get full insights.
|
||||||
> Get more help at https://help.gradle.org.
|
> Get more help at https://help.gradle.org.
|
||||||
|
|
||||||
BUILD FAILED in 1s
|
BUILD FAILED in 1s
|
||||||
14 actionable tasks: 2 executed, 12 up-to-date
|
14 actionable tasks: 2 executed, 12 up-to-date
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,37 @@
|
||||||
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
||||||
> Task :app:preBuild UP-TO-DATE
|
> Task :app:preBuild UP-TO-DATE
|
||||||
> Task :app:preDebugBuild UP-TO-DATE
|
> Task :app:preDebugBuild UP-TO-DATE
|
||||||
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
||||||
> Task :app:generateDebugResValues UP-TO-DATE
|
> Task :app:generateDebugResValues UP-TO-DATE
|
||||||
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
||||||
> Task :app:generateDebugResources UP-TO-DATE
|
> Task :app:generateDebugResources UP-TO-DATE
|
||||||
> Task :app:mergeDebugResources UP-TO-DATE
|
> Task :app:mergeDebugResources UP-TO-DATE
|
||||||
> Task :app:packageDebugResources UP-TO-DATE
|
> Task :app:packageDebugResources UP-TO-DATE
|
||||||
> Task :app:parseDebugLocalResources UP-TO-DATE
|
> Task :app:parseDebugLocalResources UP-TO-DATE
|
||||||
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
||||||
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
||||||
> Task :app:processDebugMainManifest UP-TO-DATE
|
> Task :app:processDebugMainManifest UP-TO-DATE
|
||||||
> Task :app:processDebugManifest UP-TO-DATE
|
> Task :app:processDebugManifest UP-TO-DATE
|
||||||
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
||||||
> Task :app:processDebugResources UP-TO-DATE
|
> Task :app:processDebugResources UP-TO-DATE
|
||||||
|
|
||||||
> Task :app:compileDebugKotlin FAILED
|
> Task :app:compileDebugKotlin FAILED
|
||||||
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:140:21 Cannot find a parameter with this name: onEpisodeClick
|
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:140:21 Cannot find a parameter with this name: onEpisodeClick
|
||||||
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:140:40 Cannot infer a type for this parameter. Please specify it explicitly.
|
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:140:40 Cannot infer a type for this parameter. Please specify it explicitly.
|
||||||
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:141:21 No value passed for parameter 'onEpisodeSelect'
|
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:141:21 No value passed for parameter 'onEpisodeSelect'
|
||||||
|
|
||||||
FAILURE: Build failed with an exception.
|
FAILURE: Build failed with an exception.
|
||||||
|
|
||||||
* What went wrong:
|
* What went wrong:
|
||||||
Execution failed for task ':app:compileDebugKotlin'.
|
Execution failed for task ':app:compileDebugKotlin'.
|
||||||
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
||||||
> Compilation error. See log for more details
|
> Compilation error. See log for more details
|
||||||
|
|
||||||
* Try:
|
* Try:
|
||||||
> Run with --stacktrace option to get the stack trace.
|
> Run with --stacktrace option to get the stack trace.
|
||||||
> Run with --info or --debug option to get more log output.
|
> Run with --info or --debug option to get more log output.
|
||||||
> Run with --scan to get full insights.
|
> Run with --scan to get full insights.
|
||||||
> Get more help at https://help.gradle.org.
|
> Get more help at https://help.gradle.org.
|
||||||
|
|
||||||
BUILD FAILED in 1s
|
BUILD FAILED in 1s
|
||||||
14 actionable tasks: 2 executed, 12 up-to-date
|
14 actionable tasks: 2 executed, 12 up-to-date
|
||||||
|
|
|
||||||
299
android-tv/build_error.txt
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
WARNING: A restricted method in java.lang.System has been called
|
||||||
|
WARNING: java.lang.System::load has been called by net.rubygrapefruit.platform.internal.NativeLibraryLoader in an unnamed module (file:/Users/khoa.vo/.gradle/wrapper/dists/gradle-8.9-bin/90cnw93cvbtalezasaz0blq0a/gradle-8.9/lib/native-platform-0.22-milestone-26.jar)
|
||||||
|
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
|
||||||
|
WARNING: Restricted methods will be blocked in a future release unless native access is enabled
|
||||||
|
|
||||||
|
|
||||||
|
FAILURE: Build failed with an exception.
|
||||||
|
|
||||||
|
* What went wrong:
|
||||||
|
25.0.1
|
||||||
|
|
||||||
|
* Try:
|
||||||
|
> Run with --info or --debug option to get more log output.
|
||||||
|
> Run with --scan to get full insights.
|
||||||
|
> Get more help at https://help.gradle.org.
|
||||||
|
|
||||||
|
* Exception is:
|
||||||
|
java.lang.IllegalArgumentException: 25.0.1
|
||||||
|
at org.jetbrains.kotlin.com.intellij.util.lang.JavaVersion.parse(JavaVersion.java:305)
|
||||||
|
at org.jetbrains.kotlin.com.intellij.util.lang.JavaVersion.current(JavaVersion.java:174)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.modules.JavaVersionUtilsKt.isAtLeastJava9(javaVersionUtils.kt:11)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.modules.CoreJrtFileSystem$Companion$globalJrtFsCache$1.invoke(CoreJrtFileSystem.kt:83)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.modules.CoreJrtFileSystem$Companion$globalJrtFsCache$1.invoke(CoreJrtFileSystem.kt:74)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.modules.CoreJrtFileSystem.globalJrtFsCache$lambda$1(CoreJrtFileSystem.kt:74)
|
||||||
|
at org.jetbrains.kotlin.com.intellij.util.containers.ConcurrentFactoryMap$2.create(ConcurrentFactoryMap.java:174)
|
||||||
|
at org.jetbrains.kotlin.com.intellij.util.containers.ConcurrentFactoryMap.get(ConcurrentFactoryMap.java:40)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.modules.CoreJrtFileSystem$roots$1.invoke(CoreJrtFileSystem.kt:34)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.modules.CoreJrtFileSystem$roots$1.invoke(CoreJrtFileSystem.kt:33)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.modules.CoreJrtFileSystem.roots$lambda$0(CoreJrtFileSystem.kt:33)
|
||||||
|
at org.jetbrains.kotlin.com.intellij.util.containers.ConcurrentFactoryMap$2.create(ConcurrentFactoryMap.java:174)
|
||||||
|
at org.jetbrains.kotlin.com.intellij.util.containers.ConcurrentFactoryMap.get(ConcurrentFactoryMap.java:40)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.modules.CoreJrtFileSystem.findFileByPath(CoreJrtFileSystem.kt:42)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.modules.CliJavaModuleFinder.<init>(CliJavaModuleFinder.kt:44)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment.<init>(KotlinCoreEnvironment.kt:210)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment.<init>(KotlinCoreEnvironment.kt)
|
||||||
|
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment$Companion.createForProduction(KotlinCoreEnvironment.kt:446)
|
||||||
|
at org.gradle.kotlin.dsl.support.KotlinCompilerKt$kotlinCoreEnvironmentFor$1.create(KotlinCompiler.kt:429)
|
||||||
|
at org.gradle.kotlin.dsl.support.KotlinCompilerKt$kotlinCoreEnvironmentFor$1.create(KotlinCompiler.kt:425)
|
||||||
|
at org.gradle.internal.SystemProperties.withSystemProperty(SystemProperties.java:123)
|
||||||
|
at org.gradle.kotlin.dsl.support.KotlinCompilerKt.kotlinCoreEnvironmentFor(KotlinCompiler.kt:425)
|
||||||
|
at org.gradle.kotlin.dsl.support.KotlinCompilerKt.compileKotlinScriptModuleTo(KotlinCompiler.kt:184)
|
||||||
|
at org.gradle.kotlin.dsl.support.KotlinCompilerKt.compileKotlinScriptToDirectory(KotlinCompiler.kt:148)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$compileScript$1.invoke(ResidualProgramCompiler.kt:712)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$compileScript$1.invoke(ResidualProgramCompiler.kt:711)
|
||||||
|
at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator$InterpreterHost$runCompileBuildOperation$1.call(KotlinScriptEvaluator.kt:186)
|
||||||
|
at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator$InterpreterHost$runCompileBuildOperation$1.call(KotlinScriptEvaluator.kt:183)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
|
||||||
|
at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator$InterpreterHost.runCompileBuildOperation(KotlinScriptEvaluator.kt:183)
|
||||||
|
at org.gradle.kotlin.dsl.execution.Interpreter$compile$1$1$1$1.invoke(Interpreter.kt:332)
|
||||||
|
at org.gradle.kotlin.dsl.execution.Interpreter$compile$1$1$1$1.invoke(Interpreter.kt:332)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.compileScript-C5AE47M(ResidualProgramCompiler.kt:711)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.compileStage1-EfyMToc(ResidualProgramCompiler.kt:694)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.emitStage1Sequence(ResidualProgramCompiler.kt:246)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.emitStage1Sequence(ResidualProgramCompiler.kt:237)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.emit(ResidualProgramCompiler.kt:197)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.emit(ResidualProgramCompiler.kt:181)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.access$emit(ResidualProgramCompiler.kt:83)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$emitDynamicProgram$1$1.invoke(ResidualProgramCompiler.kt:122)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$emitDynamicProgram$1$1.invoke(ResidualProgramCompiler.kt:120)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$overrideExecute$1.invoke(ResidualProgramCompiler.kt:547)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$overrideExecute$1.invoke(ResidualProgramCompiler.kt:546)
|
||||||
|
at org.gradle.kotlin.dsl.support.bytecode.AsmExtensionsKt.method(AsmExtensions.kt:131)
|
||||||
|
at org.gradle.kotlin.dsl.support.bytecode.AsmExtensionsKt.publicMethod(AsmExtensions.kt:114)
|
||||||
|
at org.gradle.kotlin.dsl.support.bytecode.AsmExtensionsKt.publicMethod$default(AsmExtensions.kt:106)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.overrideExecute(ResidualProgramCompiler.kt:546)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.access$overrideExecute(ResidualProgramCompiler.kt:83)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$emitDynamicProgram$1.invoke(ResidualProgramCompiler.kt:120)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$emitDynamicProgram$1.invoke(ResidualProgramCompiler.kt:118)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$program$3.invoke(ResidualProgramCompiler.kt:672)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler$program$3.invoke(ResidualProgramCompiler.kt:670)
|
||||||
|
at org.gradle.kotlin.dsl.support.bytecode.AsmExtensionsKt.publicClass-7y5yvvE(AsmExtensions.kt:39)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.program-5oOsWEo(ResidualProgramCompiler.kt:670)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.access$program-5oOsWEo(ResidualProgramCompiler.kt:83)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.emitDynamicProgram(ResidualProgramCompiler.kt:797)
|
||||||
|
at org.gradle.kotlin.dsl.execution.ResidualProgramCompiler.compile(ResidualProgramCompiler.kt:101)
|
||||||
|
at org.gradle.kotlin.dsl.execution.Interpreter$compile$1.invoke(Interpreter.kt:335)
|
||||||
|
at org.gradle.kotlin.dsl.execution.Interpreter$compile$1.invoke(Interpreter.kt:299)
|
||||||
|
at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator$KotlinScriptCompilationAndInstrumentation.compile(KotlinScriptEvaluator.kt:408)
|
||||||
|
at org.gradle.internal.scripts.BuildScriptCompilationAndInstrumentation.execute(BuildScriptCompilationAndInstrumentation.java:95)
|
||||||
|
at org.gradle.internal.execution.steps.ExecuteStep.executeInternal(ExecuteStep.java:105)
|
||||||
|
at org.gradle.internal.execution.steps.ExecuteStep.access$000(ExecuteStep.java:44)
|
||||||
|
at org.gradle.internal.execution.steps.ExecuteStep$1.call(ExecuteStep.java:59)
|
||||||
|
at org.gradle.internal.execution.steps.ExecuteStep$1.call(ExecuteStep.java:56)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
|
||||||
|
at org.gradle.internal.execution.steps.ExecuteStep.execute(ExecuteStep.java:56)
|
||||||
|
at org.gradle.internal.execution.steps.ExecuteStep.execute(ExecuteStep.java:44)
|
||||||
|
at org.gradle.internal.execution.steps.CancelExecutionStep.execute(CancelExecutionStep.java:42)
|
||||||
|
at org.gradle.internal.execution.steps.TimeoutStep.executeWithoutTimeout(TimeoutStep.java:75)
|
||||||
|
at org.gradle.internal.execution.steps.TimeoutStep.execute(TimeoutStep.java:55)
|
||||||
|
at org.gradle.internal.execution.steps.PreCreateOutputParentsStep.execute(PreCreateOutputParentsStep.java:50)
|
||||||
|
at org.gradle.internal.execution.steps.PreCreateOutputParentsStep.execute(PreCreateOutputParentsStep.java:28)
|
||||||
|
at org.gradle.internal.execution.steps.BroadcastChangingOutputsStep.execute(BroadcastChangingOutputsStep.java:61)
|
||||||
|
at org.gradle.internal.execution.steps.BroadcastChangingOutputsStep.execute(BroadcastChangingOutputsStep.java:26)
|
||||||
|
at org.gradle.internal.execution.steps.NoInputChangesStep.execute(NoInputChangesStep.java:30)
|
||||||
|
at org.gradle.internal.execution.steps.NoInputChangesStep.execute(NoInputChangesStep.java:21)
|
||||||
|
at org.gradle.internal.execution.steps.CaptureOutputsAfterExecutionStep.execute(CaptureOutputsAfterExecutionStep.java:69)
|
||||||
|
at org.gradle.internal.execution.steps.CaptureOutputsAfterExecutionStep.execute(CaptureOutputsAfterExecutionStep.java:46)
|
||||||
|
at org.gradle.internal.execution.steps.BuildCacheStep.executeWithoutCache(BuildCacheStep.java:189)
|
||||||
|
at org.gradle.internal.execution.steps.BuildCacheStep.lambda$execute$1(BuildCacheStep.java:75)
|
||||||
|
at org.gradle.internal.Either$Right.fold(Either.java:175)
|
||||||
|
at org.gradle.internal.execution.caching.CachingState.fold(CachingState.java:62)
|
||||||
|
at org.gradle.internal.execution.steps.BuildCacheStep.execute(BuildCacheStep.java:73)
|
||||||
|
at org.gradle.internal.execution.steps.BuildCacheStep.execute(BuildCacheStep.java:48)
|
||||||
|
at org.gradle.internal.execution.steps.NeverUpToDateStep.execute(NeverUpToDateStep.java:34)
|
||||||
|
at org.gradle.internal.execution.steps.NeverUpToDateStep.execute(NeverUpToDateStep.java:22)
|
||||||
|
at org.gradle.internal.execution.steps.legacy.MarkSnapshottingInputsFinishedStep.execute(MarkSnapshottingInputsFinishedStep.java:37)
|
||||||
|
at org.gradle.internal.execution.steps.legacy.MarkSnapshottingInputsFinishedStep.execute(MarkSnapshottingInputsFinishedStep.java:27)
|
||||||
|
at org.gradle.internal.execution.steps.ResolveNonIncrementalCachingStateStep.executeDelegate(ResolveNonIncrementalCachingStateStep.java:50)
|
||||||
|
at org.gradle.internal.execution.steps.AbstractResolveCachingStateStep.execute(AbstractResolveCachingStateStep.java:71)
|
||||||
|
at org.gradle.internal.execution.steps.AbstractResolveCachingStateStep.execute(AbstractResolveCachingStateStep.java:39)
|
||||||
|
at org.gradle.internal.execution.steps.ValidateStep.execute(ValidateStep.java:105)
|
||||||
|
at org.gradle.internal.execution.steps.ValidateStep.execute(ValidateStep.java:54)
|
||||||
|
at org.gradle.internal.execution.steps.AbstractCaptureStateBeforeExecutionStep.execute(AbstractCaptureStateBeforeExecutionStep.java:64)
|
||||||
|
at org.gradle.internal.execution.steps.AbstractCaptureStateBeforeExecutionStep.execute(AbstractCaptureStateBeforeExecutionStep.java:43)
|
||||||
|
at org.gradle.internal.execution.steps.legacy.MarkSnapshottingInputsStartedStep.execute(MarkSnapshottingInputsStartedStep.java:38)
|
||||||
|
at org.gradle.internal.execution.steps.AssignImmutableWorkspaceStep.lambda$executeInTemporaryWorkspace$3(AssignImmutableWorkspaceStep.java:209)
|
||||||
|
at org.gradle.internal.execution.workspace.impl.CacheBasedImmutableWorkspaceProvider$1.withTemporaryWorkspace(CacheBasedImmutableWorkspaceProvider.java:119)
|
||||||
|
at org.gradle.internal.execution.steps.AssignImmutableWorkspaceStep.executeInTemporaryWorkspace(AssignImmutableWorkspaceStep.java:199)
|
||||||
|
at org.gradle.internal.execution.steps.AssignImmutableWorkspaceStep.lambda$execute$0(AssignImmutableWorkspaceStep.java:121)
|
||||||
|
at org.gradle.internal.execution.steps.AssignImmutableWorkspaceStep.execute(AssignImmutableWorkspaceStep.java:121)
|
||||||
|
at org.gradle.internal.execution.steps.AssignImmutableWorkspaceStep.execute(AssignImmutableWorkspaceStep.java:90)
|
||||||
|
at org.gradle.internal.execution.steps.ChoosePipelineStep.execute(ChoosePipelineStep.java:38)
|
||||||
|
at org.gradle.internal.execution.steps.ChoosePipelineStep.execute(ChoosePipelineStep.java:23)
|
||||||
|
at org.gradle.internal.execution.steps.ExecuteWorkBuildOperationFiringStep.lambda$execute$2(ExecuteWorkBuildOperationFiringStep.java:67)
|
||||||
|
at org.gradle.internal.execution.steps.ExecuteWorkBuildOperationFiringStep.execute(ExecuteWorkBuildOperationFiringStep.java:67)
|
||||||
|
at org.gradle.internal.execution.steps.ExecuteWorkBuildOperationFiringStep.execute(ExecuteWorkBuildOperationFiringStep.java:39)
|
||||||
|
at org.gradle.internal.execution.steps.IdentityCacheStep.execute(IdentityCacheStep.java:46)
|
||||||
|
at org.gradle.internal.execution.steps.IdentityCacheStep.execute(IdentityCacheStep.java:34)
|
||||||
|
at org.gradle.internal.execution.steps.IdentifyStep.execute(IdentifyStep.java:48)
|
||||||
|
at org.gradle.internal.execution.steps.IdentifyStep.execute(IdentifyStep.java:35)
|
||||||
|
at org.gradle.internal.execution.impl.DefaultExecutionEngine$1.execute(DefaultExecutionEngine.java:61)
|
||||||
|
at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator$InterpreterHost.cachedDirFor(KotlinScriptEvaluator.kt:278)
|
||||||
|
at org.gradle.kotlin.dsl.execution.Interpreter.compile(Interpreter.kt:299)
|
||||||
|
at org.gradle.kotlin.dsl.execution.Interpreter.emitSpecializedProgramFor(Interpreter.kt:266)
|
||||||
|
at org.gradle.kotlin.dsl.execution.Interpreter.eval(Interpreter.kt:198)
|
||||||
|
at org.gradle.kotlin.dsl.provider.StandardKotlinScriptEvaluator.evaluate(KotlinScriptEvaluator.kt:124)
|
||||||
|
at org.gradle.kotlin.dsl.provider.KotlinScriptPluginFactory$create$1.invoke(KotlinScriptPluginFactory.kt:51)
|
||||||
|
at org.gradle.kotlin.dsl.provider.KotlinScriptPluginFactory$create$1.invoke(KotlinScriptPluginFactory.kt:48)
|
||||||
|
at org.gradle.kotlin.dsl.provider.KotlinScriptPlugin.apply(KotlinScriptPlugin.kt:35)
|
||||||
|
at org.gradle.configuration.BuildOperationScriptPlugin$1.run(BuildOperationScriptPlugin.java:68)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:29)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:26)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.run(DefaultBuildOperationRunner.java:47)
|
||||||
|
at org.gradle.configuration.BuildOperationScriptPlugin.lambda$apply$0(BuildOperationScriptPlugin.java:65)
|
||||||
|
at org.gradle.internal.code.DefaultUserCodeApplicationContext.apply(DefaultUserCodeApplicationContext.java:44)
|
||||||
|
at org.gradle.configuration.BuildOperationScriptPlugin.apply(BuildOperationScriptPlugin.java:65)
|
||||||
|
at org.gradle.initialization.ScriptEvaluatingSettingsProcessor.applySettingsScript(ScriptEvaluatingSettingsProcessor.java:75)
|
||||||
|
at org.gradle.initialization.ScriptEvaluatingSettingsProcessor.process(ScriptEvaluatingSettingsProcessor.java:68)
|
||||||
|
at org.gradle.initialization.SettingsEvaluatedCallbackFiringSettingsProcessor.process(SettingsEvaluatedCallbackFiringSettingsProcessor.java:34)
|
||||||
|
at org.gradle.initialization.RootBuildCacheControllerSettingsProcessor.process(RootBuildCacheControllerSettingsProcessor.java:47)
|
||||||
|
at org.gradle.initialization.BuildOperationSettingsProcessor$2.call(BuildOperationSettingsProcessor.java:49)
|
||||||
|
at org.gradle.initialization.BuildOperationSettingsProcessor$2.call(BuildOperationSettingsProcessor.java:46)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
|
||||||
|
at org.gradle.initialization.BuildOperationSettingsProcessor.process(BuildOperationSettingsProcessor.java:46)
|
||||||
|
at org.gradle.initialization.DefaultSettingsLoader.findSettingsAndLoadIfAppropriate(DefaultSettingsLoader.java:143)
|
||||||
|
at org.gradle.initialization.DefaultSettingsLoader.findAndLoadSettings(DefaultSettingsLoader.java:63)
|
||||||
|
at org.gradle.initialization.SettingsAttachingSettingsLoader.findAndLoadSettings(SettingsAttachingSettingsLoader.java:33)
|
||||||
|
at org.gradle.internal.composite.CommandLineIncludedBuildSettingsLoader.findAndLoadSettings(CommandLineIncludedBuildSettingsLoader.java:35)
|
||||||
|
at org.gradle.internal.composite.ChildBuildRegisteringSettingsLoader.findAndLoadSettings(ChildBuildRegisteringSettingsLoader.java:44)
|
||||||
|
at org.gradle.internal.composite.CompositeBuildSettingsLoader.findAndLoadSettings(CompositeBuildSettingsLoader.java:35)
|
||||||
|
at org.gradle.initialization.InitScriptHandlingSettingsLoader.findAndLoadSettings(InitScriptHandlingSettingsLoader.java:33)
|
||||||
|
at org.gradle.api.internal.initialization.CacheConfigurationsHandlingSettingsLoader.findAndLoadSettings(CacheConfigurationsHandlingSettingsLoader.java:36)
|
||||||
|
at org.gradle.initialization.GradlePropertiesHandlingSettingsLoader.findAndLoadSettings(GradlePropertiesHandlingSettingsLoader.java:38)
|
||||||
|
at org.gradle.initialization.DefaultSettingsPreparer.prepareSettings(DefaultSettingsPreparer.java:31)
|
||||||
|
at org.gradle.initialization.BuildOperationFiringSettingsPreparer$LoadBuild.doLoadBuild(BuildOperationFiringSettingsPreparer.java:71)
|
||||||
|
at org.gradle.initialization.BuildOperationFiringSettingsPreparer$LoadBuild.run(BuildOperationFiringSettingsPreparer.java:66)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:29)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:26)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.run(DefaultBuildOperationRunner.java:47)
|
||||||
|
at org.gradle.initialization.BuildOperationFiringSettingsPreparer.prepareSettings(BuildOperationFiringSettingsPreparer.java:54)
|
||||||
|
at org.gradle.initialization.VintageBuildModelController.lambda$prepareSettings$1(VintageBuildModelController.java:80)
|
||||||
|
at org.gradle.internal.model.StateTransitionController.lambda$doTransition$14(StateTransitionController.java:255)
|
||||||
|
at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:266)
|
||||||
|
at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:254)
|
||||||
|
at org.gradle.internal.model.StateTransitionController.lambda$transitionIfNotPreviously$11(StateTransitionController.java:213)
|
||||||
|
at org.gradle.internal.work.DefaultSynchronizer.withLock(DefaultSynchronizer.java:34)
|
||||||
|
at org.gradle.internal.model.StateTransitionController.transitionIfNotPreviously(StateTransitionController.java:209)
|
||||||
|
at org.gradle.initialization.VintageBuildModelController.prepareSettings(VintageBuildModelController.java:80)
|
||||||
|
at org.gradle.initialization.VintageBuildModelController.prepareToScheduleTasks(VintageBuildModelController.java:70)
|
||||||
|
at org.gradle.internal.build.DefaultBuildLifecycleController.lambda$prepareToScheduleTasks$6(DefaultBuildLifecycleController.java:175)
|
||||||
|
at org.gradle.internal.model.StateTransitionController.lambda$doTransition$14(StateTransitionController.java:255)
|
||||||
|
at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:266)
|
||||||
|
at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:254)
|
||||||
|
at org.gradle.internal.model.StateTransitionController.lambda$maybeTransition$9(StateTransitionController.java:190)
|
||||||
|
at org.gradle.internal.work.DefaultSynchronizer.withLock(DefaultSynchronizer.java:34)
|
||||||
|
at org.gradle.internal.model.StateTransitionController.maybeTransition(StateTransitionController.java:186)
|
||||||
|
at org.gradle.internal.build.DefaultBuildLifecycleController.prepareToScheduleTasks(DefaultBuildLifecycleController.java:173)
|
||||||
|
at org.gradle.internal.buildtree.DefaultBuildTreeWorkPreparer.scheduleRequestedTasks(DefaultBuildTreeWorkPreparer.java:36)
|
||||||
|
at org.gradle.internal.cc.impl.VintageBuildTreeWorkController$scheduleAndRunRequestedTasks$1.apply(VintageBuildTreeWorkController.kt:36)
|
||||||
|
at org.gradle.internal.cc.impl.VintageBuildTreeWorkController$scheduleAndRunRequestedTasks$1.apply(VintageBuildTreeWorkController.kt:35)
|
||||||
|
at org.gradle.composite.internal.DefaultIncludedBuildTaskGraph.withNewWorkGraph(DefaultIncludedBuildTaskGraph.java:112)
|
||||||
|
at org.gradle.internal.cc.impl.VintageBuildTreeWorkController.scheduleAndRunRequestedTasks(VintageBuildTreeWorkController.kt:35)
|
||||||
|
at org.gradle.internal.buildtree.DefaultBuildTreeLifecycleController.lambda$scheduleAndRunTasks$1(DefaultBuildTreeLifecycleController.java:77)
|
||||||
|
at org.gradle.internal.buildtree.DefaultBuildTreeLifecycleController.lambda$runBuild$4(DefaultBuildTreeLifecycleController.java:120)
|
||||||
|
at org.gradle.internal.model.StateTransitionController.lambda$transition$6(StateTransitionController.java:169)
|
||||||
|
at org.gradle.internal.model.StateTransitionController.doTransition(StateTransitionController.java:266)
|
||||||
|
at org.gradle.internal.model.StateTransitionController.lambda$transition$7(StateTransitionController.java:169)
|
||||||
|
at org.gradle.internal.work.DefaultSynchronizer.withLock(DefaultSynchronizer.java:44)
|
||||||
|
at org.gradle.internal.model.StateTransitionController.transition(StateTransitionController.java:169)
|
||||||
|
at org.gradle.internal.buildtree.DefaultBuildTreeLifecycleController.runBuild(DefaultBuildTreeLifecycleController.java:117)
|
||||||
|
at org.gradle.internal.buildtree.DefaultBuildTreeLifecycleController.scheduleAndRunTasks(DefaultBuildTreeLifecycleController.java:77)
|
||||||
|
at org.gradle.internal.buildtree.DefaultBuildTreeLifecycleController.scheduleAndRunTasks(DefaultBuildTreeLifecycleController.java:72)
|
||||||
|
at org.gradle.tooling.internal.provider.ExecuteBuildActionRunner.run(ExecuteBuildActionRunner.java:31)
|
||||||
|
at org.gradle.launcher.exec.ChainingBuildActionRunner.run(ChainingBuildActionRunner.java:35)
|
||||||
|
at org.gradle.internal.buildtree.ProblemReportingBuildActionRunner.run(ProblemReportingBuildActionRunner.java:49)
|
||||||
|
at org.gradle.launcher.exec.BuildOutcomeReportingBuildActionRunner.run(BuildOutcomeReportingBuildActionRunner.java:65)
|
||||||
|
at org.gradle.tooling.internal.provider.FileSystemWatchingBuildActionRunner.run(FileSystemWatchingBuildActionRunner.java:140)
|
||||||
|
at org.gradle.launcher.exec.BuildCompletionNotifyingBuildActionRunner.run(BuildCompletionNotifyingBuildActionRunner.java:41)
|
||||||
|
at org.gradle.launcher.exec.RootBuildLifecycleBuildActionExecutor.lambda$execute$0(RootBuildLifecycleBuildActionExecutor.java:40)
|
||||||
|
at org.gradle.composite.internal.DefaultRootBuildState.run(DefaultRootBuildState.java:130)
|
||||||
|
at org.gradle.launcher.exec.RootBuildLifecycleBuildActionExecutor.execute(RootBuildLifecycleBuildActionExecutor.java:40)
|
||||||
|
at org.gradle.internal.buildtree.InitDeprecationLoggingActionExecutor.execute(InitDeprecationLoggingActionExecutor.java:62)
|
||||||
|
at org.gradle.internal.buildtree.InitProblems.execute(InitProblems.java:36)
|
||||||
|
at org.gradle.internal.buildtree.DefaultBuildTreeContext.execute(DefaultBuildTreeContext.java:40)
|
||||||
|
at org.gradle.launcher.exec.BuildTreeLifecycleBuildActionExecutor.lambda$execute$0(BuildTreeLifecycleBuildActionExecutor.java:71)
|
||||||
|
at org.gradle.internal.buildtree.BuildTreeState.run(BuildTreeState.java:60)
|
||||||
|
at org.gradle.launcher.exec.BuildTreeLifecycleBuildActionExecutor.execute(BuildTreeLifecycleBuildActionExecutor.java:71)
|
||||||
|
at org.gradle.launcher.exec.RunAsBuildOperationBuildActionExecutor$3.call(RunAsBuildOperationBuildActionExecutor.java:61)
|
||||||
|
at org.gradle.launcher.exec.RunAsBuildOperationBuildActionExecutor$3.call(RunAsBuildOperationBuildActionExecutor.java:57)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
|
||||||
|
at org.gradle.launcher.exec.RunAsBuildOperationBuildActionExecutor.execute(RunAsBuildOperationBuildActionExecutor.java:57)
|
||||||
|
at org.gradle.launcher.exec.RunAsWorkerThreadBuildActionExecutor.lambda$execute$0(RunAsWorkerThreadBuildActionExecutor.java:36)
|
||||||
|
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:267)
|
||||||
|
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:131)
|
||||||
|
at org.gradle.launcher.exec.RunAsWorkerThreadBuildActionExecutor.execute(RunAsWorkerThreadBuildActionExecutor.java:36)
|
||||||
|
at org.gradle.tooling.internal.provider.continuous.ContinuousBuildActionExecutor.execute(ContinuousBuildActionExecutor.java:110)
|
||||||
|
at org.gradle.tooling.internal.provider.SubscribableBuildActionExecutor.execute(SubscribableBuildActionExecutor.java:64)
|
||||||
|
at org.gradle.internal.session.DefaultBuildSessionContext.execute(DefaultBuildSessionContext.java:46)
|
||||||
|
at org.gradle.internal.buildprocess.execution.BuildSessionLifecycleBuildActionExecutor$ActionImpl.apply(BuildSessionLifecycleBuildActionExecutor.java:92)
|
||||||
|
at org.gradle.internal.buildprocess.execution.BuildSessionLifecycleBuildActionExecutor$ActionImpl.apply(BuildSessionLifecycleBuildActionExecutor.java:80)
|
||||||
|
at org.gradle.internal.session.BuildSessionState.run(BuildSessionState.java:71)
|
||||||
|
at org.gradle.internal.buildprocess.execution.BuildSessionLifecycleBuildActionExecutor.execute(BuildSessionLifecycleBuildActionExecutor.java:62)
|
||||||
|
at org.gradle.internal.buildprocess.execution.BuildSessionLifecycleBuildActionExecutor.execute(BuildSessionLifecycleBuildActionExecutor.java:41)
|
||||||
|
at org.gradle.internal.buildprocess.execution.StartParamsValidatingActionExecutor.execute(StartParamsValidatingActionExecutor.java:64)
|
||||||
|
at org.gradle.internal.buildprocess.execution.StartParamsValidatingActionExecutor.execute(StartParamsValidatingActionExecutor.java:32)
|
||||||
|
at org.gradle.internal.buildprocess.execution.SessionFailureReportingActionExecutor.execute(SessionFailureReportingActionExecutor.java:51)
|
||||||
|
at org.gradle.internal.buildprocess.execution.SessionFailureReportingActionExecutor.execute(SessionFailureReportingActionExecutor.java:39)
|
||||||
|
at org.gradle.internal.buildprocess.execution.SetupLoggingActionExecutor.execute(SetupLoggingActionExecutor.java:47)
|
||||||
|
at org.gradle.internal.buildprocess.execution.SetupLoggingActionExecutor.execute(SetupLoggingActionExecutor.java:31)
|
||||||
|
at org.gradle.launcher.daemon.server.exec.ExecuteBuild.doBuild(ExecuteBuild.java:70)
|
||||||
|
at org.gradle.launcher.daemon.server.exec.BuildCommandOnly.execute(BuildCommandOnly.java:37)
|
||||||
|
at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
|
||||||
|
at org.gradle.launcher.daemon.server.exec.WatchForDisconnection.execute(WatchForDisconnection.java:39)
|
||||||
|
at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
|
||||||
|
at org.gradle.launcher.daemon.server.exec.ResetDeprecationLogger.execute(ResetDeprecationLogger.java:29)
|
||||||
|
at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
|
||||||
|
at org.gradle.launcher.daemon.server.exec.RequestStopIfSingleUsedDaemon.execute(RequestStopIfSingleUsedDaemon.java:35)
|
||||||
|
at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
|
||||||
|
at org.gradle.launcher.daemon.server.exec.ForwardClientInput.lambda$execute$0(ForwardClientInput.java:40)
|
||||||
|
at org.gradle.internal.daemon.clientinput.ClientInputForwarder.forwardInput(ClientInputForwarder.java:80)
|
||||||
|
at org.gradle.launcher.daemon.server.exec.ForwardClientInput.execute(ForwardClientInput.java:37)
|
||||||
|
at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
|
||||||
|
at org.gradle.launcher.daemon.server.exec.LogAndCheckHealth.execute(LogAndCheckHealth.java:64)
|
||||||
|
at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
|
||||||
|
at org.gradle.launcher.daemon.server.exec.LogToClient.doBuild(LogToClient.java:63)
|
||||||
|
at org.gradle.launcher.daemon.server.exec.BuildCommandOnly.execute(BuildCommandOnly.java:37)
|
||||||
|
at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
|
||||||
|
at org.gradle.launcher.daemon.server.exec.EstablishBuildEnvironment.doBuild(EstablishBuildEnvironment.java:84)
|
||||||
|
at org.gradle.launcher.daemon.server.exec.BuildCommandOnly.execute(BuildCommandOnly.java:37)
|
||||||
|
at org.gradle.launcher.daemon.server.api.DaemonCommandExecution.proceed(DaemonCommandExecution.java:104)
|
||||||
|
at org.gradle.launcher.daemon.server.exec.StartBuildOrRespondWithBusy$1.run(StartBuildOrRespondWithBusy.java:52)
|
||||||
|
at org.gradle.launcher.daemon.server.DaemonStateCoordinator.lambda$runCommand$0(DaemonStateCoordinator.java:320)
|
||||||
|
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
|
||||||
|
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48)
|
||||||
|
|
||||||
|
|
||||||
|
BUILD FAILED in 4s
|
||||||
|
|
@ -1,92 +1,92 @@
|
||||||
@rem
|
@rem
|
||||||
@rem Copyright 2015 the original author or authors.
|
@rem Copyright 2015 the original author or authors.
|
||||||
@rem
|
@rem
|
||||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
@rem you may not use this file except in compliance with the License.
|
@rem you may not use this file except in compliance with the License.
|
||||||
@rem You may obtain a copy of the License at
|
@rem You may obtain a copy of the License at
|
||||||
@rem
|
@rem
|
||||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
@rem
|
@rem
|
||||||
@rem Unless required by applicable law or agreed to in writing, software
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
@rem See the License for the specific language governing permissions and
|
@rem See the License for the specific language governing permissions and
|
||||||
@rem limitations under the License.
|
@rem limitations under the License.
|
||||||
@rem
|
@rem
|
||||||
|
|
||||||
@if "%DEBUG%"=="" @echo off
|
@if "%DEBUG%"=="" @echo off
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@rem Gradle startup script for Windows
|
@rem Gradle startup script for Windows
|
||||||
@rem
|
@rem
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
@rem Set local scope for the variables with windows NT shell
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
set DIRNAME=%~dp0
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
@rem This is normally unused
|
@rem This is normally unused
|
||||||
set APP_BASE_NAME=%~n0
|
set APP_BASE_NAME=%~n0
|
||||||
set APP_HOME=%DIRNAME%..
|
set APP_HOME=%DIRNAME%..
|
||||||
|
|
||||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" "-javaagent:%APP_HOME%/lib/agents/gradle-instrumentation-agent-8.4.jar"
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" "-javaagent:%APP_HOME%/lib/agents/gradle-instrumentation-agent-8.4.jar"
|
||||||
|
|
||||||
@rem Find java.exe
|
@rem Find java.exe
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
set JAVA_EXE=java.exe
|
set JAVA_EXE=java.exe
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
echo.
|
echo.
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
echo location of your Java installation.
|
echo location of your Java installation.
|
||||||
|
|
||||||
goto fail
|
goto fail
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
:findJavaFromJavaHome
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
echo.
|
echo.
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
echo location of your Java installation.
|
echo location of your Java installation.
|
||||||
|
|
||||||
goto fail
|
goto fail
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\lib\gradle-launcher-8.4.jar
|
set CLASSPATH=%APP_HOME%\lib\gradle-launcher-8.4.jar
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
@rem Execute Gradle
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.launcher.GradleMain %*
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.launcher.GradleMain %*
|
||||||
|
|
||||||
:end
|
:end
|
||||||
@rem End local scope for the variables with windows NT shell
|
@rem End local scope for the variables with windows NT shell
|
||||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
:fail
|
:fail
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
rem the _cmd.exe /c_ return code!
|
rem the _cmd.exe /c_ return code!
|
||||||
set EXIT_CODE=%ERRORLEVEL%
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
exit /b %EXIT_CODE%
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
:mainEnd
|
:mainEnd
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
:omega
|
:omega
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
|
|
||||||
0
android-tv/gradlew
vendored
Normal file → Executable file
188
android-tv/gradlew.bat
vendored
|
|
@ -1,94 +1,94 @@
|
||||||
@rem
|
@rem
|
||||||
@rem Copyright 2015 the original author or authors.
|
@rem Copyright 2015 the original author or authors.
|
||||||
@rem
|
@rem
|
||||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
@rem you may not use this file except in compliance with the License.
|
@rem you may not use this file except in compliance with the License.
|
||||||
@rem You may obtain a copy of the License at
|
@rem You may obtain a copy of the License at
|
||||||
@rem
|
@rem
|
||||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
@rem
|
@rem
|
||||||
@rem Unless required by applicable law or agreed to in writing, software
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
@rem See the License for the specific language governing permissions and
|
@rem See the License for the specific language governing permissions and
|
||||||
@rem limitations under the License.
|
@rem limitations under the License.
|
||||||
@rem
|
@rem
|
||||||
@rem SPDX-License-Identifier: Apache-2.0
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
@rem
|
@rem
|
||||||
|
|
||||||
@if "%DEBUG%"=="" @echo off
|
@if "%DEBUG%"=="" @echo off
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@rem Gradle startup script for Windows
|
@rem Gradle startup script for Windows
|
||||||
@rem
|
@rem
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
@rem Set local scope for the variables with windows NT shell
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
set DIRNAME=%~dp0
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
@rem This is normally unused
|
@rem This is normally unused
|
||||||
set APP_BASE_NAME=%~n0
|
set APP_BASE_NAME=%~n0
|
||||||
set APP_HOME=%DIRNAME%
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
@rem Find java.exe
|
@rem Find java.exe
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
set JAVA_EXE=java.exe
|
set JAVA_EXE=java.exe
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
echo. 1>&2
|
echo. 1>&2
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
echo. 1>&2
|
echo. 1>&2
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo location of your Java installation. 1>&2
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
goto fail
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
:findJavaFromJavaHome
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
echo. 1>&2
|
echo. 1>&2
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
echo. 1>&2
|
echo. 1>&2
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo location of your Java installation. 1>&2
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
goto fail
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
@rem Execute Gradle
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
:end
|
:end
|
||||||
@rem End local scope for the variables with windows NT shell
|
@rem End local scope for the variables with windows NT shell
|
||||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
:fail
|
:fail
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
rem the _cmd.exe /c_ return code!
|
rem the _cmd.exe /c_ return code!
|
||||||
set EXIT_CODE=%ERRORLEVEL%
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
exit /b %EXIT_CODE%
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
:mainEnd
|
:mainEnd
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
:omega
|
:omega
|
||||||
|
|
|
||||||
46385
android-tv/logcat.txt
Normal file
2151
android-tv/logcat2.txt
Normal file
|
|
@ -1,18 +1,18 @@
|
||||||
pluginManagement {
|
pluginManagement {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "StreamFlowTV"
|
rootProject.name = "StreamFlowTV"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|
|
||||||
BIN
backend/cache/images/009a4648cfbc3528f6708a6c41a8c0df.jpg
vendored
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
backend/cache/images/0224c715cd96ade6ed1b45408791878e.jpg
vendored
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
backend/cache/images/11e8ffb2a3d869beef0e03ca0ffcdded.jpg
vendored
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
backend/cache/images/154415651d73422adc4fac6b636ad35c.jpg
vendored
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
backend/cache/images/25ead2464d25d3aebea55dc12a486409.jpg
vendored
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
backend/cache/images/2909ef6d7ea6665614cbafa7031afe6b.jpg
vendored
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
backend/cache/images/415af139057262a5d7de7c0221798754.jpg
vendored
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
backend/cache/images/42f7927393e3e167fd7436d11ca45cc4.jpg
vendored
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
backend/cache/images/4913254e3cd3b6f449b7626cfb00abbd.jpg
vendored
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
backend/cache/images/495a29ba9081df8481098a2ca0796726.jpg
vendored
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
backend/cache/images/563ab85daf0361b446a6a58f690e1fb5.jpg
vendored
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
backend/cache/images/5bb569278362af207d22a2a13784f0b0.jpg
vendored
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
backend/cache/images/64db92774b883117c531ff1b7dc8b830.jpg
vendored
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
backend/cache/images/66debc10258e9b71054ed7d52d7a8b1f.jpg
vendored
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
backend/cache/images/6c7531274e55385f37edbd47bbae6d5f.jpg
vendored
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
backend/cache/images/72257fdd325da9f90124996dbd7a03f7.jpg
vendored
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
backend/cache/images/8adcb8fdb317f3ceb665e387d504c7c5.jpg
vendored
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
backend/cache/images/977f099768c8d5d8c597460f3ecbbe78.jpg
vendored
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
backend/cache/images/a94aee6527e4a8949cd23f5a15d3a9dd.jpg
vendored
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
backend/cache/images/add0acdf732315b6d8faf1340da1bffa.jpg
vendored
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
backend/cache/images/af974f055b1ffb40d1b1dbfaa142d837.jpg
vendored
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
backend/cache/images/bed842ab15f277935c30824818641942.jpg
vendored
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
backend/cache/images/c23b2cb67ea0078953aa9437b5a01d9b.jpg
vendored
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
backend/cache/images/cba6e90d7955f81e277ea987e7af1d27.jpg
vendored
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
backend/cache/images/cf2f4f9cbffd4eda60316fc031fa6336.jpg
vendored
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
backend/cache/images/d9dfb0eb46f632fa74a35701dbf2c644.jpg
vendored
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
backend/cache/images/da25e479917b419ea8710368b29892d6.jpg
vendored
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
backend/cache/images/de55d41cdb1be82063846c590588550b.jpg
vendored
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
backend/cache/images/e1c57185978a94e87d16c83b971f874e.jpg
vendored
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
backend/cache/images/e482cbee52cde885e88519f2ec680142.jpg
vendored
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
backend/cache/images/ec8ab4998a26430d13349e62f7453a1f.jpg
vendored
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
backend/cache/images/f191da9141b464a956ca73b32252afb3.jpg
vendored
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
backend/cache/images/f27a350429c57e23b363016424e5389b.jpg
vendored
Normal file
|
After Width: | Height: | Size: 29 KiB |
|
|
@ -75,6 +75,7 @@ func (h *Handler) GetHomeVideos(w http.ResponseWriter, r *http.Request) {
|
||||||
return p.GetMoviesByCategory(category, page)
|
return p.GetMoviesByCategory(category, page)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(movies)
|
json.NewEncoder(w).Encode(movies)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,6 +95,7 @@ func (h *Handler) SearchVideos(w http.ResponseWriter, r *http.Request) {
|
||||||
return p.Search(query, page)
|
return p.Search(query, page)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(movies)
|
json.NewEncoder(w).Encode(movies)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,6 +213,7 @@ func (h *Handler) ExtractVideo(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(info)
|
json.NewEncoder(w).Encode(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,16 +299,18 @@ func (h *Handler) GetMovieDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
if len(primaryMovie.Episodes) > 0 {
|
if len(primaryMovie.Episodes) > 0 {
|
||||||
uniqueEps := make([]models.Episode, 0)
|
uniqueEps := make([]models.Episode, 0)
|
||||||
seenEpNums := make(map[int]bool)
|
seenEpNums := make(map[string]bool)
|
||||||
for _, ep := range primaryMovie.Episodes {
|
for _, ep := range primaryMovie.Episodes {
|
||||||
if !seenEpNums[ep.Number] {
|
key := fmt.Sprintf("%d-%s", ep.Number, ep.ServerName)
|
||||||
seenEpNums[ep.Number] = true
|
if !seenEpNums[key] {
|
||||||
|
seenEpNums[key] = true
|
||||||
uniqueEps = append(uniqueEps, ep)
|
uniqueEps = append(uniqueEps, ep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
primaryMovie.Episodes = uniqueEps
|
primaryMovie.Episodes = uniqueEps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(primaryMovie)
|
json.NewEncoder(w).Encode(primaryMovie)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,6 +321,7 @@ func (h *Handler) GetGenres(w http.ResponseWriter, r *http.Request) {
|
||||||
}); ok {
|
}); ok {
|
||||||
genres, err := gp.GetGenres()
|
genres, err := gp.GetGenres()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(genres)
|
json.NewEncoder(w).Encode(genres)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -331,6 +337,7 @@ func (h *Handler) GetCountries(w http.ResponseWriter, r *http.Request) {
|
||||||
}); ok {
|
}); ok {
|
||||||
countries, err := cp.GetCountries()
|
countries, err := cp.GetCountries()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(countries)
|
json.NewEncoder(w).Encode(countries)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -425,21 +432,23 @@ func (h *Handler) mergeMovieMetadata(existing, new *models.RophimMovie) {
|
||||||
existing.Quality = new.Quality
|
existing.Quality = new.Quality
|
||||||
}
|
}
|
||||||
|
|
||||||
epMap := make(map[int]int)
|
epMap := make(map[string]int)
|
||||||
for i := range existing.Episodes {
|
for i, ep := range existing.Episodes {
|
||||||
epMap[existing.Episodes[i].Number] = i
|
key := fmt.Sprintf("%d-%s", ep.Number, ep.ServerName)
|
||||||
|
epMap[key] = i
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range new.Episodes {
|
for i := range new.Episodes {
|
||||||
newEp := &new.Episodes[i]
|
newEp := &new.Episodes[i]
|
||||||
if idx, exists := epMap[newEp.Number]; exists {
|
key := fmt.Sprintf("%d-%s", newEp.Number, newEp.ServerName)
|
||||||
|
if idx, exists := epMap[key]; exists {
|
||||||
if existing.Episodes[idx].URL == "" && newEp.URL != "" {
|
if existing.Episodes[idx].URL == "" && newEp.URL != "" {
|
||||||
existing.Episodes[idx].URL = newEp.URL
|
existing.Episodes[idx].URL = newEp.URL
|
||||||
existing.Episodes[idx].Title = newEp.Title
|
existing.Episodes[idx].Title = newEp.Title
|
||||||
existing.Episodes[idx].ServerName = newEp.ServerName
|
existing.Episodes[idx].ServerName = newEp.ServerName
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
epMap[newEp.Number] = len(existing.Episodes)
|
epMap[key] = len(existing.Episodes)
|
||||||
existing.Episodes = append(existing.Episodes, *newEp)
|
existing.Episodes = append(existing.Episodes, *newEp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterRoutes(r chi.Router, h *Handler) {
|
func RegisterRoutes(r chi.Router, h *Handler) {
|
||||||
r.Get("/videos/home", h.GetHomeVideos)
|
r.Get("/videos/home", h.GetHomeVideos)
|
||||||
r.Get("/videos/search", h.SearchVideos)
|
r.Get("/videos/search", h.SearchVideos)
|
||||||
r.Get("/videos/{slug}", h.GetMovieDetail)
|
r.Get("/videos/{slug}", h.GetMovieDetail)
|
||||||
r.Post("/extract", h.ExtractVideo)
|
r.Post("/extract", h.ExtractVideo)
|
||||||
r.Get("/images/proxy", h.ProxyImage)
|
r.Get("/images/proxy", h.ProxyImage)
|
||||||
r.Get("/categories/genres", h.GetGenres)
|
r.Get("/categories/genres", h.GetGenres)
|
||||||
r.Get("/categories/countries", h.GetCountries)
|
r.Get("/categories/countries", h.GetCountries)
|
||||||
r.Get("/stream", h.StreamVideo)
|
r.Get("/stream", h.StreamVideo)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,85 @@
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"streamflow-backend/internal/models"
|
"streamflow-backend/internal/models"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
var DB *gorm.DB
|
var DB *gorm.DB
|
||||||
|
|
||||||
func InitDB(dsn string) {
|
func InitDB(dsn string) {
|
||||||
var err error
|
var err error
|
||||||
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Info),
|
Logger: logger.Default.LogMode(logger.Info),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Failed to connect to database:", err)
|
log.Fatal("Failed to connect to database:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Database connection established")
|
log.Println("Database connection established")
|
||||||
|
|
||||||
// Auto-migrate schema
|
// Auto-migrate schema
|
||||||
err = DB.AutoMigrate(&models.Video{})
|
err = DB.AutoMigrate(&models.Video{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Failed to migrate database:", err)
|
log.Fatal("Failed to migrate database:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type VideoRepository struct {
|
type VideoRepository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewVideoRepository(db *gorm.DB) *VideoRepository {
|
func NewVideoRepository(db *gorm.DB) *VideoRepository {
|
||||||
return &VideoRepository{db: db}
|
return &VideoRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *VideoRepository) Create(video *models.Video) error {
|
func (r *VideoRepository) Create(video *models.Video) error {
|
||||||
return r.db.Create(video).Error
|
return r.db.Create(video).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *VideoRepository) GetByID(id uint) (*models.Video, error) {
|
func (r *VideoRepository) GetByID(id uint) (*models.Video, error) {
|
||||||
var video models.Video
|
var video models.Video
|
||||||
err := r.db.First(&video, id).Error
|
err := r.db.First(&video, id).Error
|
||||||
return &video, err
|
return &video, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *VideoRepository) GetBySourceURL(url string) (*models.Video, error) {
|
func (r *VideoRepository) GetBySourceURL(url string) (*models.Video, error) {
|
||||||
var video models.Video
|
var video models.Video
|
||||||
err := r.db.Where("source_url = ?", url).First(&video).Error
|
err := r.db.Where("source_url = ?", url).First(&video).Error
|
||||||
return &video, err
|
return &video, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *VideoRepository) Search(query string, limit int) ([]models.Video, error) {
|
func (r *VideoRepository) Search(query string, limit int) ([]models.Video, error) {
|
||||||
var videos []models.Video
|
var videos []models.Video
|
||||||
err := r.db.Where("title LIKE ?", "%"+query+"%").Limit(limit).Find(&videos).Error
|
err := r.db.Where("title LIKE ?", "%"+query+"%").Limit(limit).Find(&videos).Error
|
||||||
return videos, err
|
return videos, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *VideoRepository) GetAll(skip int, limit int) ([]models.Video, error) {
|
func (r *VideoRepository) GetAll(skip int, limit int) ([]models.Video, error) {
|
||||||
var videos []models.Video
|
var videos []models.Video
|
||||||
err := r.db.Offset(skip).Limit(limit).Find(&videos).Error
|
err := r.db.Offset(skip).Limit(limit).Find(&videos).Error
|
||||||
return videos, err
|
return videos, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *VideoRepository) Update(id uint, updates map[string]interface{}) (*models.Video, error) {
|
func (r *VideoRepository) Update(id uint, updates map[string]interface{}) (*models.Video, error) {
|
||||||
var video models.Video
|
var video models.Video
|
||||||
result := r.db.First(&video, id)
|
result := r.db.First(&video, id)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return nil, result.Error
|
return nil, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
err := r.db.Model(&video).Updates(updates).Error
|
err := r.db.Model(&video).Updates(updates).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &video, nil
|
return &video, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *VideoRepository) Delete(id uint) error {
|
func (r *VideoRepository) Delete(id uint) error {
|
||||||
return r.db.Delete(&models.Video{}, id).Error
|
return r.db.Delete(&models.Video{}, id).Error
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,56 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Video metadata model matches SQLAlchemy Video class
|
// Video metadata model matches SQLAlchemy Video class
|
||||||
type Video struct {
|
type Video struct {
|
||||||
ID uint `json:"id" gorm:"primaryKey"`
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
Title string `json:"title" gorm:"index;size:500"`
|
Title string `json:"title" gorm:"index;size:500"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Thumbnail string `json:"thumbnail" gorm:"size:1000"`
|
Thumbnail string `json:"thumbnail" gorm:"size:1000"`
|
||||||
SourceURL string `json:"source_url" gorm:"uniqueIndex;size:2000"`
|
SourceURL string `json:"source_url" gorm:"uniqueIndex;size:2000"`
|
||||||
Duration int `json:"duration" gorm:"default:0"`
|
Duration int `json:"duration" gorm:"default:0"`
|
||||||
Resolution string `json:"resolution" gorm:"size:20"`
|
Resolution string `json:"resolution" gorm:"size:20"`
|
||||||
Category string `json:"category" gorm:"index;size:100"`
|
Category string `json:"category" gorm:"index;size:100"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RophimMovie represents the scraped movie data
|
// RophimMovie represents the scraped movie data
|
||||||
type RophimMovie struct {
|
type RophimMovie struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
OriginalTitle string `json:"original_title,omitempty"`
|
OriginalTitle string `json:"original_title,omitempty"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Thumbnail string `json:"thumbnail"`
|
Thumbnail string `json:"thumbnail"`
|
||||||
Backdrop string `json:"backdrop,omitempty"`
|
Backdrop string `json:"backdrop,omitempty"`
|
||||||
Year int `json:"year,omitempty"`
|
Year int `json:"year,omitempty"`
|
||||||
Rating string `json:"rating,omitempty"`
|
Rating string `json:"rating,omitempty"`
|
||||||
Duration int `json:"duration,omitempty"`
|
Duration int `json:"duration,omitempty"`
|
||||||
Time string `json:"time,omitempty"` // Raw time string
|
Time string `json:"time,omitempty"` // Raw time string
|
||||||
Quality string `json:"quality,omitempty"`
|
Quality string `json:"quality,omitempty"`
|
||||||
Lang string `json:"lang,omitempty"`
|
Lang string `json:"lang,omitempty"`
|
||||||
Genre string `json:"genre,omitempty"`
|
Genre string `json:"genre,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Provider string `json:"provider,omitempty"`
|
Provider string `json:"provider,omitempty"`
|
||||||
Cast []string `json:"cast,omitempty" gorm:"-"`
|
Cast []string `json:"cast,omitempty" gorm:"-"`
|
||||||
Director string `json:"director,omitempty"`
|
Director string `json:"director,omitempty"`
|
||||||
Country string `json:"country,omitempty"`
|
Country string `json:"country,omitempty"`
|
||||||
Episodes []Episode `json:"episodes,omitempty" gorm:"-"`
|
Episodes []Episode `json:"episodes,omitempty" gorm:"-"`
|
||||||
TrailerURL string `json:"trailer_url,omitempty"`
|
TrailerURL string `json:"trailer_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Episode struct {
|
type Episode struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
ServerName string `json:"server_name"`
|
ServerName string `json:"server_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Category struct {
|
type Category struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -220,12 +220,12 @@ func (s *OphimScraper) fetchAndParseList(url string) ([]models.RophimMovie, erro
|
||||||
thumb := item.ThumbURL
|
thumb := item.ThumbURL
|
||||||
if !strings.HasPrefix(thumb, "http") {
|
if !strings.HasPrefix(thumb, "http") {
|
||||||
// Search API might return relative paths too
|
// Search API might return relative paths too
|
||||||
thumb = "https://img.ophim1.com/uploads/movies/" + thumb
|
thumb = "https://img.ophim.live/uploads/movies/" + thumb
|
||||||
}
|
}
|
||||||
|
|
||||||
backdrop := item.PosterURL
|
backdrop := item.PosterURL
|
||||||
if !strings.HasPrefix(backdrop, "http") {
|
if !strings.HasPrefix(backdrop, "http") {
|
||||||
backdrop = "https://img.ophim1.com/uploads/movies/" + backdrop
|
backdrop = "https://img.ophim.live/uploads/movies/" + backdrop
|
||||||
}
|
}
|
||||||
|
|
||||||
movies = append(movies, models.RophimMovie{
|
movies = append(movies, models.RophimMovie{
|
||||||
|
|
@ -273,12 +273,12 @@ func (s *OphimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error)
|
||||||
|
|
||||||
thumb := movie.ThumbURL
|
thumb := movie.ThumbURL
|
||||||
if !strings.HasPrefix(thumb, "http") {
|
if !strings.HasPrefix(thumb, "http") {
|
||||||
thumb = "https://img.ophim1.com/uploads/movies/" + thumb
|
thumb = "https://img.ophim.live/uploads/movies/" + thumb
|
||||||
}
|
}
|
||||||
|
|
||||||
backdrop := movie.PosterURL
|
backdrop := movie.PosterURL
|
||||||
if !strings.HasPrefix(backdrop, "http") {
|
if !strings.HasPrefix(backdrop, "http") {
|
||||||
backdrop = "https://img.ophim1.com/uploads/movies/" + backdrop
|
backdrop = "https://img.ophim.live/uploads/movies/" + backdrop
|
||||||
}
|
}
|
||||||
|
|
||||||
var episodes []models.Episode
|
var episodes []models.Episode
|
||||||
|
|
|
||||||
|
|
@ -1,191 +1,237 @@
|
||||||
package scraper
|
package scraper
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"streamflow-backend/internal/models"
|
"streamflow-backend/internal/models"
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
)
|
)
|
||||||
|
|
||||||
func parseEpisodeNumber(title string) int {
|
func parseEpisodeNumber(title string) int {
|
||||||
// e.g "Tập 1", "Tập 01", "Full"
|
// e.g "Tập 1", "Tập 01", "Full"
|
||||||
t := strings.ToLower(strings.TrimSpace(title))
|
t := strings.ToLower(strings.TrimSpace(title))
|
||||||
if t == "full" {
|
if t == "full" {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
t = strings.ReplaceAll(t, "tập ", "")
|
t = strings.ReplaceAll(t, "tập ", "")
|
||||||
t = strings.ReplaceAll(t, "tap ", "")
|
t = strings.ReplaceAll(t, "tap ", "")
|
||||||
|
|
||||||
// handle multi-spaces
|
// handle multi-spaces
|
||||||
parts := strings.Fields(t)
|
parts := strings.Fields(t)
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
num, err := strconv.Atoi(parts[0])
|
num, err := strconv.Atoi(parts[0])
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return num
|
return num
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const Phim30BaseURL = "https://phim30.me"
|
const Phim30BaseURL = "https://phim30.me"
|
||||||
|
|
||||||
type Phim30Scraper struct {
|
type Phim30Scraper struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPhim30Scraper() *Phim30Scraper {
|
func NewPhim30Scraper() *Phim30Scraper {
|
||||||
return &Phim30Scraper{
|
return &Phim30Scraper{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Phim30Scraper) Search(query string, page int) ([]models.RophimMovie, error) {
|
func (p *Phim30Scraper) Search(query string, page int) ([]models.RophimMovie, error) {
|
||||||
searchURL := fmt.Sprintf("%s/tim-kiem?keyword=%s&page=%d", Phim30BaseURL, url.QueryEscape(query), page)
|
searchURL := fmt.Sprintf("%s/tim-kiem?keyword=%s&page=%d", Phim30BaseURL, url.QueryEscape(query), page)
|
||||||
return p.scrapeMovieList(searchURL)
|
return p.scrapeMovieList(searchURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Phim30Scraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) {
|
func (p *Phim30Scraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) {
|
||||||
// e.g. https://phim30.me/the-loai/hanh-dong?page=1
|
if category == "" || category == "home" {
|
||||||
catURL := fmt.Sprintf("%s/the-loai/%s?page=%d", Phim30BaseURL, category, page)
|
homeURL := fmt.Sprintf("%s/?page=%d", Phim30BaseURL, page)
|
||||||
return p.scrapeMovieList(catURL)
|
return p.scrapeMovieList(homeURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie, error) {
|
var path string
|
||||||
req, err := http.NewRequest("GET", targetURL, nil)
|
switch category {
|
||||||
if err != nil {
|
case "phim-le", "phim-bo", "phim-sap-chieu":
|
||||||
return nil, err
|
path = fmt.Sprintf("danh-sach/%s", category)
|
||||||
}
|
default:
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
// Assume everything else is a Genre (e.g., hanh-dong, hoat-hinh, tv-shows)
|
||||||
|
path = fmt.Sprintf("the-loai/%s", category)
|
||||||
resp, err := p.client.Do(req)
|
}
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
catURL := fmt.Sprintf("%s/%s?page=%d", Phim30BaseURL, path, page)
|
||||||
}
|
return p.scrapeMovieList(catURL)
|
||||||
defer resp.Body.Close()
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
func cleanImageUrl(rawURL string) string {
|
||||||
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
|
if strings.Contains(rawURL, "cdn-image-tf.phim30.me") {
|
||||||
}
|
// Example: https://cdn-image-tf.phim30.me/unsafe/360x0/filters:quality(90)/https%3A%2F%2Fphimimg.com%2Fupload%2Fvod%2F...
|
||||||
|
parts := strings.SplitN(rawURL, "/https", 2)
|
||||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
if len(parts) == 2 {
|
||||||
if err != nil {
|
decoded, err := url.QueryUnescape("https" + parts[1])
|
||||||
return nil, err
|
if err == nil {
|
||||||
}
|
return decoded
|
||||||
|
}
|
||||||
var movies []models.RophimMovie
|
}
|
||||||
|
}
|
||||||
doc.Find("a[href^='https://phim30.me/phim/']").Each(func(i int, s *goquery.Selection) {
|
return rawURL
|
||||||
href, _ := s.Attr("href")
|
}
|
||||||
title, _ := s.Attr("title")
|
|
||||||
|
func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie, error) {
|
||||||
// Remove the base url to get the slug
|
req, err := http.NewRequest("GET", targetURL, nil)
|
||||||
slug := strings.TrimPrefix(href, "https://phim30.me/phim/")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
// Try to find an image child (check data-src for lazy-loaded images)
|
}
|
||||||
thumb := ""
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
s.Find("img").Each(func(j int, img *goquery.Selection) {
|
|
||||||
src, _ := img.Attr("src")
|
resp, err := p.client.Do(req)
|
||||||
dataSrc, _ := img.Attr("data-src")
|
if err != nil {
|
||||||
lazySrc, _ := img.Attr("lazy-src")
|
return nil, err
|
||||||
if dataSrc != "" {
|
}
|
||||||
thumb = dataSrc
|
defer resp.Body.Close()
|
||||||
} else if lazySrc != "" {
|
|
||||||
thumb = lazySrc
|
if resp.StatusCode != http.StatusOK {
|
||||||
} else if src != "" && !strings.Contains(src, "data:image") {
|
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
|
||||||
thumb = src
|
}
|
||||||
}
|
|
||||||
})
|
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||||
|
if err != nil {
|
||||||
if title != "" && slug != "" {
|
return nil, err
|
||||||
movies = append(movies, models.RophimMovie{
|
}
|
||||||
ID: slug,
|
|
||||||
Slug: slug,
|
var movies []models.RophimMovie
|
||||||
Title: title,
|
|
||||||
OriginalTitle: title,
|
doc.Find("a[href^='https://phim30.me/phim/']").Each(func(i int, s *goquery.Selection) {
|
||||||
Thumbnail: thumb,
|
href, _ := s.Attr("href")
|
||||||
})
|
title, _ := s.Attr("title")
|
||||||
}
|
|
||||||
})
|
if title == "" {
|
||||||
|
title = strings.TrimSpace(s.Text())
|
||||||
// Deduplicate movies because a search page might have multiple links to the same movie
|
}
|
||||||
var uniqueMovies []models.RophimMovie
|
|
||||||
seen := make(map[string]bool)
|
// Remove the base url to get the slug
|
||||||
for _, m := range movies {
|
slug := strings.TrimPrefix(href, "https://phim30.me/phim/")
|
||||||
if !seen[m.Slug] {
|
|
||||||
seen[m.Slug] = true
|
// Try to find an image child (check data-src for lazy-loaded images)
|
||||||
uniqueMovies = append(uniqueMovies, m)
|
thumb := ""
|
||||||
}
|
s.Find("img").Each(func(j int, img *goquery.Selection) {
|
||||||
}
|
src, _ := img.Attr("src")
|
||||||
|
dataSrc, _ := img.Attr("data-src")
|
||||||
return uniqueMovies, nil
|
lazySrc, _ := img.Attr("lazy-src")
|
||||||
}
|
if dataSrc != "" {
|
||||||
|
thumb = dataSrc
|
||||||
func (p *Phim30Scraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
|
} else if lazySrc != "" {
|
||||||
targetURL := fmt.Sprintf("%s/phim/%s", Phim30BaseURL, slug)
|
thumb = lazySrc
|
||||||
req, err := http.NewRequest("GET", targetURL, nil)
|
} else if src != "" && !strings.Contains(src, "data:image") {
|
||||||
if err != nil {
|
thumb = src
|
||||||
return nil, err
|
}
|
||||||
}
|
})
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
|
||||||
|
if title != "" && slug != "" && !strings.Contains(slug, "the-loai") && !strings.Contains(slug, "quoc-gia") && !strings.Contains(slug, "nam-phat-hanh") {
|
||||||
resp, err := p.client.Do(req)
|
movies = append(movies, models.RophimMovie{
|
||||||
if err != nil {
|
ID: slug,
|
||||||
return nil, err
|
Slug: slug,
|
||||||
}
|
Title: title,
|
||||||
defer resp.Body.Close()
|
OriginalTitle: title,
|
||||||
|
Thumbnail: cleanImageUrl(thumb),
|
||||||
if resp.StatusCode != http.StatusOK {
|
Backdrop: cleanImageUrl(thumb),
|
||||||
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
|
Provider: "Phim30.me",
|
||||||
}
|
})
|
||||||
|
}
|
||||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
})
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
// Deduplicate movies because a search page might have multiple links to the same movie
|
||||||
}
|
var uniqueMovies []models.RophimMovie
|
||||||
|
seen := make(map[string]bool)
|
||||||
movie := &models.RophimMovie{
|
for _, m := range movies {
|
||||||
ID: slug,
|
if !seen[m.Slug] {
|
||||||
Slug: slug,
|
seen[m.Slug] = true
|
||||||
}
|
uniqueMovies = append(uniqueMovies, m)
|
||||||
|
}
|
||||||
title := doc.Find("h1.movie-title").Text()
|
}
|
||||||
if title == "" {
|
|
||||||
title = doc.Find("title").Text()
|
return uniqueMovies, nil
|
||||||
title = strings.Split(title, "–")[0]
|
}
|
||||||
title = strings.TrimSpace(title)
|
|
||||||
}
|
func (p *Phim30Scraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
|
||||||
movie.Title = title
|
targetURL := fmt.Sprintf("%s/phim/%s", Phim30BaseURL, slug)
|
||||||
movie.OriginalTitle = title
|
req, err := http.NewRequest("GET", targetURL, nil)
|
||||||
|
if err != nil {
|
||||||
var eps []models.Episode
|
return nil, err
|
||||||
doc.Find("a[href*='/xem-phim/']").Each(func(i int, s *goquery.Selection) {
|
}
|
||||||
href, _ := s.Attr("href")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
epName := strings.TrimSpace(s.Text())
|
|
||||||
|
resp, err := p.client.Do(req)
|
||||||
if epName != "" && href != "" {
|
if err != nil {
|
||||||
if !strings.HasPrefix(href, "http") {
|
return nil, err
|
||||||
href = Phim30BaseURL + href
|
}
|
||||||
}
|
defer resp.Body.Close()
|
||||||
eps = append(eps, models.Episode{
|
|
||||||
ServerName: "Phim30",
|
if resp.StatusCode != http.StatusOK {
|
||||||
Title: epName,
|
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
|
||||||
Number: parseEpisodeNumber(epName),
|
}
|
||||||
URL: href,
|
|
||||||
})
|
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||||
}
|
if err != nil {
|
||||||
})
|
return nil, err
|
||||||
|
}
|
||||||
if len(eps) > 0 {
|
|
||||||
movie.Episodes = eps
|
movie := &models.RophimMovie{
|
||||||
}
|
ID: slug,
|
||||||
|
Slug: slug,
|
||||||
return movie, nil
|
}
|
||||||
}
|
|
||||||
|
title := doc.Find("h1.movie-title").Text()
|
||||||
|
if title == "" {
|
||||||
|
title = doc.Find("title").Text()
|
||||||
|
title = strings.Split(title, "–")[0]
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
|
}
|
||||||
|
movie.Title = title
|
||||||
|
movie.OriginalTitle = title
|
||||||
|
|
||||||
|
thumb := ""
|
||||||
|
doc.Find("div.movie-l-img img").Each(func(i int, img *goquery.Selection) {
|
||||||
|
if src, ok := img.Attr("src"); ok {
|
||||||
|
thumb = src
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if thumb != "" {
|
||||||
|
movie.Thumbnail = cleanImageUrl(thumb)
|
||||||
|
movie.Backdrop = cleanImageUrl(thumb)
|
||||||
|
}
|
||||||
|
|
||||||
|
movie.Provider = "Phim30.me"
|
||||||
|
|
||||||
|
var eps []models.Episode
|
||||||
|
doc.Find("a[href*='/xem-phim/']").Each(func(i int, s *goquery.Selection) {
|
||||||
|
href, _ := s.Attr("href")
|
||||||
|
epName := strings.TrimSpace(s.Text())
|
||||||
|
|
||||||
|
if epName != "" && href != "" {
|
||||||
|
if !strings.HasPrefix(href, "http") {
|
||||||
|
href = Phim30BaseURL + href
|
||||||
|
}
|
||||||
|
eps = append(eps, models.Episode{
|
||||||
|
ServerName: "Phim30",
|
||||||
|
Title: epName,
|
||||||
|
Number: parseEpisodeNumber(epName),
|
||||||
|
URL: href,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(eps) > 0 {
|
||||||
|
movie.Episodes = eps
|
||||||
|
}
|
||||||
|
|
||||||
|
return movie, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
package scraper
|
package scraper
|
||||||
|
|
||||||
import "streamflow-backend/internal/models"
|
import "streamflow-backend/internal/models"
|
||||||
|
|
||||||
type MovieProvider interface {
|
type MovieProvider interface {
|
||||||
GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error)
|
GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error)
|
||||||
GetMovieDetail(slug string) (*models.RophimMovie, error)
|
GetMovieDetail(slug string) (*models.RophimMovie, error)
|
||||||
Search(query string, page int) ([]models.RophimMovie, error)
|
Search(query string, page int) ([]models.RophimMovie, error)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,246 +1,246 @@
|
||||||
package scraper
|
package scraper
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"streamflow-backend/internal/models"
|
"streamflow-backend/internal/models"
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
)
|
)
|
||||||
|
|
||||||
const BaseURL = "https://phimmoichill.network"
|
const BaseURL = "https://phimmoichill.network"
|
||||||
|
|
||||||
type RophimScraper struct {
|
type RophimScraper struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRophimScraper() *RophimScraper {
|
func NewRophimScraper() *RophimScraper {
|
||||||
// Create custom client to handle SSL constraints if needed, similar to Python's ssl_context
|
// Create custom client to handle SSL constraints if needed, similar to Python's ssl_context
|
||||||
tr := &http.Transport{
|
tr := &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
}
|
}
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Transport: tr,
|
Transport: tr,
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
}
|
}
|
||||||
return &RophimScraper{client: client}
|
return &RophimScraper{client: client}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *RophimScraper) fetchDocument(url string) (*goquery.Document, error) {
|
func (s *RophimScraper) fetchDocument(url string) (*goquery.Document, error) {
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
|
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
|
||||||
req.Header.Set("Referer", BaseURL)
|
req.Header.Set("Referer", BaseURL)
|
||||||
|
|
||||||
resp, err := s.client.Do(req)
|
resp, err := s.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status)
|
return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
return goquery.NewDocumentFromReader(resp.Body)
|
return goquery.NewDocumentFromReader(resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *RophimScraper) GetHomepageMovies(page int, limit int) ([]models.RophimMovie, error) {
|
func (s *RophimScraper) GetHomepageMovies(page int, limit int) ([]models.RophimMovie, error) {
|
||||||
url := fmt.Sprintf("%s/danh-sach/phim-le", BaseURL)
|
url := fmt.Sprintf("%s/danh-sach/phim-le", BaseURL)
|
||||||
if page > 1 {
|
if page > 1 {
|
||||||
url = fmt.Sprintf("%s/danh-sach/phim-le/page/%d", BaseURL, page)
|
url = fmt.Sprintf("%s/danh-sach/phim-le/page/%d", BaseURL, page)
|
||||||
}
|
}
|
||||||
|
|
||||||
doc, err := s.fetchDocument(url)
|
doc, err := s.fetchDocument(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.parseMovieGrid(doc, limit), nil
|
return s.parseMovieGrid(doc, limit), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *RophimScraper) Search(query string, limit int) ([]models.RophimMovie, error) {
|
func (s *RophimScraper) Search(query string, limit int) ([]models.RophimMovie, error) {
|
||||||
url := fmt.Sprintf("%s/tim-kiem?keyword=%s", BaseURL, query)
|
url := fmt.Sprintf("%s/tim-kiem?keyword=%s", BaseURL, query)
|
||||||
doc, err := s.fetchDocument(url)
|
doc, err := s.fetchDocument(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.parseMovieGrid(doc, limit), nil
|
return s.parseMovieGrid(doc, limit), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *RophimScraper) parseMovieGrid(doc *goquery.Document, limit int) []models.RophimMovie {
|
func (s *RophimScraper) parseMovieGrid(doc *goquery.Document, limit int) []models.RophimMovie {
|
||||||
var movies []models.RophimMovie
|
var movies []models.RophimMovie
|
||||||
|
|
||||||
doc.Find(".myui-vodlist__box").EachWithBreak(func(i int, s *goquery.Selection) bool {
|
doc.Find(".myui-vodlist__box").EachWithBreak(func(i int, s *goquery.Selection) bool {
|
||||||
if i >= limit {
|
if i >= limit {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
link := s.Find("a.myui-vodlist__thumb")
|
link := s.Find("a.myui-vodlist__thumb")
|
||||||
if link.Length() == 0 {
|
if link.Length() == 0 {
|
||||||
link = s.Find("a[href*='/phim/']")
|
link = s.Find("a[href*='/phim/']")
|
||||||
}
|
}
|
||||||
if link.Length() == 0 {
|
if link.Length() == 0 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
href, _ := link.Attr("href")
|
href, _ := link.Attr("href")
|
||||||
slug := extractSlug(href)
|
slug := extractSlug(href)
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
title, _ := link.Attr("title")
|
title, _ := link.Attr("title")
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = s.Find("h4.title a").Text()
|
title = s.Find("h4.title a").Text()
|
||||||
}
|
}
|
||||||
|
|
||||||
style, _ := link.Attr("style")
|
style, _ := link.Attr("style")
|
||||||
thumbnail := extractThumbnail(style)
|
thumbnail := extractThumbnail(style)
|
||||||
if thumbnail == "" {
|
if thumbnail == "" {
|
||||||
thumbnail, _ = s.Find("img").Attr("src")
|
thumbnail, _ = s.Find("img").Attr("src")
|
||||||
}
|
}
|
||||||
|
|
||||||
quality := s.Find(".pic-tag").Text()
|
quality := s.Find(".pic-tag").Text()
|
||||||
if quality == "" {
|
if quality == "" {
|
||||||
quality = "HD"
|
quality = "HD"
|
||||||
}
|
}
|
||||||
|
|
||||||
engTitle := s.Find(".text-muted").Text()
|
engTitle := s.Find(".text-muted").Text()
|
||||||
|
|
||||||
movie := models.RophimMovie{
|
movie := models.RophimMovie{
|
||||||
ID: slug,
|
ID: slug,
|
||||||
Title: strings.TrimSpace(title),
|
Title: strings.TrimSpace(title),
|
||||||
OriginalTitle: strings.TrimSpace(engTitle),
|
OriginalTitle: strings.TrimSpace(engTitle),
|
||||||
Slug: slug,
|
Slug: slug,
|
||||||
Thumbnail: normalizeURL(thumbnail),
|
Thumbnail: normalizeURL(thumbnail),
|
||||||
Quality: strings.TrimSpace(quality),
|
Quality: strings.TrimSpace(quality),
|
||||||
Category: "movies", // Default
|
Category: "movies", // Default
|
||||||
}
|
}
|
||||||
movies = append(movies, movie)
|
movies = append(movies, movie)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
return movies
|
return movies
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *RophimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
|
func (s *RophimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
|
||||||
url := fmt.Sprintf("%s/phim/%s", BaseURL, slug)
|
url := fmt.Sprintf("%s/phim/%s", BaseURL, slug)
|
||||||
doc, err := s.fetchDocument(url)
|
doc, err := s.fetchDocument(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.parseMovieDetail(doc, slug), nil
|
return s.parseMovieDetail(doc, slug), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *RophimScraper) parseMovieDetail(doc *goquery.Document, slug string) *models.RophimMovie {
|
func (s *RophimScraper) parseMovieDetail(doc *goquery.Document, slug string) *models.RophimMovie {
|
||||||
title := doc.Find("h1.movie-title").Text()
|
title := doc.Find("h1.movie-title").Text()
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = doc.Find("h1").Text()
|
title = doc.Find("h1").Text()
|
||||||
}
|
}
|
||||||
|
|
||||||
description := doc.Find("meta[name='description']").AttrOr("content", "")
|
description := doc.Find("meta[name='description']").AttrOr("content", "")
|
||||||
if description == "" {
|
if description == "" {
|
||||||
description = doc.Find(".description, .content, .film-description").Text()
|
description = doc.Find(".description, .content, .film-description").Text()
|
||||||
}
|
}
|
||||||
|
|
||||||
poster := doc.Find("meta[property='og:image']").AttrOr("content", "")
|
poster := doc.Find("meta[property='og:image']").AttrOr("content", "")
|
||||||
|
|
||||||
// Parse Info (Year, Country, etc) - simplified for brevity
|
// Parse Info (Year, Country, etc) - simplified for brevity
|
||||||
var year int
|
var year int
|
||||||
doc.Find(".movie-info li, .film-info li").Each(func(i int, s *goquery.Selection) {
|
doc.Find(".movie-info li, .film-info li").Each(func(i int, s *goquery.Selection) {
|
||||||
text := s.Text()
|
text := s.Text()
|
||||||
if strings.Contains(text, "Năm") || strings.Contains(text, "Year") {
|
if strings.Contains(text, "Năm") || strings.Contains(text, "Year") {
|
||||||
re := regexp.MustCompile(`\d{4}`)
|
re := regexp.MustCompile(`\d{4}`)
|
||||||
if match := re.FindString(text); match != "" {
|
if match := re.FindString(text); match != "" {
|
||||||
year, _ = strconv.Atoi(match)
|
year, _ = strconv.Atoi(match)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Parse Episodes
|
// Parse Episodes
|
||||||
var episodes []models.Episode
|
var episodes []models.Episode
|
||||||
doc.Find("a[href*='/tap-'], a[href*='episode'], .episode-list a").Each(func(i int, s *goquery.Selection) {
|
doc.Find("a[href*='/tap-'], a[href*='episode'], .episode-list a").Each(func(i int, s *goquery.Selection) {
|
||||||
href, _ := s.Attr("href")
|
href, _ := s.Attr("href")
|
||||||
text := strings.TrimSpace(s.Text())
|
text := strings.TrimSpace(s.Text())
|
||||||
|
|
||||||
re := regexp.MustCompile(`tap-(\d+)`)
|
re := regexp.MustCompile(`tap-(\d+)`)
|
||||||
match := re.FindStringSubmatch(href)
|
match := re.FindStringSubmatch(href)
|
||||||
if len(match) > 1 {
|
if len(match) > 1 {
|
||||||
epNum, _ := strconv.Atoi(match[1])
|
epNum, _ := strconv.Atoi(match[1])
|
||||||
episodes = append(episodes, models.Episode{
|
episodes = append(episodes, models.Episode{
|
||||||
Number: epNum,
|
Number: epNum,
|
||||||
Title: text,
|
Title: text,
|
||||||
URL: normalizeURL(href),
|
URL: normalizeURL(href),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// De-duplicate episodes
|
// De-duplicate episodes
|
||||||
seen := make(map[int]bool)
|
seen := make(map[int]bool)
|
||||||
var uniqueEpisodes []models.Episode
|
var uniqueEpisodes []models.Episode
|
||||||
for _, ep := range episodes {
|
for _, ep := range episodes {
|
||||||
if !seen[ep.Number] {
|
if !seen[ep.Number] {
|
||||||
seen[ep.Number] = true
|
seen[ep.Number] = true
|
||||||
uniqueEpisodes = append(uniqueEpisodes, ep)
|
uniqueEpisodes = append(uniqueEpisodes, ep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &models.RophimMovie{
|
return &models.RophimMovie{
|
||||||
ID: slug,
|
ID: slug,
|
||||||
Title: strings.TrimSpace(title),
|
Title: strings.TrimSpace(title),
|
||||||
Slug: slug,
|
Slug: slug,
|
||||||
Thumbnail: normalizeURL(poster),
|
Thumbnail: normalizeURL(poster),
|
||||||
Description: strings.TrimSpace(description),
|
Description: strings.TrimSpace(description),
|
||||||
Year: year,
|
Year: year,
|
||||||
Episodes: uniqueEpisodes,
|
Episodes: uniqueEpisodes,
|
||||||
Category: "movies",
|
Category: "movies",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractSlug(url string) string {
|
func extractSlug(url string) string {
|
||||||
re := regexp.MustCompile(`/phim/([^/?#]+)`)
|
re := regexp.MustCompile(`/phim/([^/?#]+)`)
|
||||||
matches := re.FindStringSubmatch(url)
|
matches := re.FindStringSubmatch(url)
|
||||||
if len(matches) > 1 {
|
if len(matches) > 1 {
|
||||||
return matches[1]
|
return matches[1]
|
||||||
}
|
}
|
||||||
// Fallback
|
// Fallback
|
||||||
parts := strings.Split(url, "/")
|
parts := strings.Split(url, "/")
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
return parts[len(parts)-1]
|
return parts[len(parts)-1]
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractThumbnail(style string) string {
|
func extractThumbnail(style string) string {
|
||||||
re := regexp.MustCompile(`url\(([^)]+)\)`)
|
re := regexp.MustCompile(`url\(([^)]+)\)`)
|
||||||
matches := re.FindStringSubmatch(style)
|
matches := re.FindStringSubmatch(style)
|
||||||
if len(matches) > 1 {
|
if len(matches) > 1 {
|
||||||
return strings.Trim(matches[1], "'\"")
|
return strings.Trim(matches[1], "'\"")
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeURL(url string) string {
|
func normalizeURL(url string) string {
|
||||||
if url == "" {
|
if url == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(url, "//") {
|
if strings.HasPrefix(url, "//") {
|
||||||
return "https:" + url
|
return "https:" + url
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(url, "/") {
|
if strings.HasPrefix(url, "/") {
|
||||||
return BaseURL + url
|
return BaseURL + url
|
||||||
}
|
}
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,96 +1,127 @@
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"net/http"
|
||||||
"os/exec"
|
"os"
|
||||||
"path/filepath"
|
"os/exec"
|
||||||
"strings"
|
"path/filepath"
|
||||||
"time"
|
"strings"
|
||||||
)
|
"time"
|
||||||
|
|
||||||
type VideoInfo struct {
|
"github.com/PuerkitoBio/goquery"
|
||||||
Title string `json:"title"`
|
)
|
||||||
Thumbnail string `json:"thumbnail"`
|
|
||||||
Duration int `json:"duration"`
|
type VideoInfo struct {
|
||||||
StreamURL string `json:"url"` // yt-dlp JSON key is 'url'
|
Title string `json:"title"`
|
||||||
FormatID string `json:"format_id"`
|
Thumbnail string `json:"thumbnail"`
|
||||||
Resolution string `json:"resolution"` // Custom field
|
Duration int `json:"duration"`
|
||||||
Ext string `json:"ext"`
|
StreamURL string `json:"url"` // yt-dlp JSON key is 'url'
|
||||||
}
|
FormatID string `json:"format_id"`
|
||||||
|
Resolution string `json:"resolution"` // Custom field
|
||||||
type VideoExtractor struct{}
|
Ext string `json:"ext"`
|
||||||
|
}
|
||||||
func NewVideoExtractor() *VideoExtractor {
|
|
||||||
return &VideoExtractor{}
|
type VideoExtractor struct{}
|
||||||
}
|
|
||||||
|
func NewVideoExtractor() *VideoExtractor {
|
||||||
func (e *VideoExtractor) Extract(url string, quality string) (*VideoInfo, error) {
|
return &VideoExtractor{}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
}
|
||||||
defer cancel()
|
|
||||||
|
func (e *VideoExtractor) Extract(url string, quality string) (*VideoInfo, error) {
|
||||||
// Check for custom extractors
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
if strings.Contains(url, "phim30.me") {
|
defer cancel()
|
||||||
// Currently returning the URL as-is, letting yt-dlp attempt extraction
|
|
||||||
// or allowing the frontend iframe to handle it directly if it's embeddable
|
// Check for custom extractors
|
||||||
}
|
if strings.Contains(url, "phim30.me") {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
// Build format selector
|
if err != nil {
|
||||||
formatSelector := "bestvideo+bestaudio/best"
|
return nil, fmt.Errorf("failed to create phim30 request: %v", err)
|
||||||
if quality != "" {
|
}
|
||||||
height := strings.Replace(quality, "p", "", -1)
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
formatSelector = fmt.Sprintf("bestvideo[height<=%s]+bestaudio/best[height<=%s]/best", height, height)
|
|
||||||
}
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
args := []string{
|
if err != nil {
|
||||||
"--dump-json",
|
return nil, fmt.Errorf("failed to fetch phim30 page: %v", err)
|
||||||
"--no-playlist",
|
}
|
||||||
"--no-warnings",
|
defer resp.Body.Close()
|
||||||
"--format", formatSelector,
|
|
||||||
url,
|
if resp.StatusCode != http.StatusOK {
|
||||||
}
|
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
// Check for local yt-dlp.exe
|
|
||||||
ytDlpCmd := "yt-dlp"
|
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||||
// Only on windows for simplicity or check OS
|
if err != nil {
|
||||||
if _, err := os.Stat("yt-dlp.exe"); err == nil {
|
return nil, fmt.Errorf("failed to parse phim30 page: %v", err)
|
||||||
path, _ := filepath.Abs("yt-dlp.exe")
|
}
|
||||||
ytDlpCmd = path
|
|
||||||
}
|
streamURL, _ := doc.Find("[data-movie-player-src-value]").Attr("data-movie-player-src-value")
|
||||||
|
if streamURL != "" {
|
||||||
cmd := exec.CommandContext(ctx, ytDlpCmd, args...)
|
return &VideoInfo{
|
||||||
output, err := cmd.Output()
|
StreamURL: streamURL,
|
||||||
if err != nil {
|
Resolution: "unknown",
|
||||||
return nil, fmt.Errorf("extraction failed: %v", err)
|
}, nil
|
||||||
}
|
}
|
||||||
|
return nil, fmt.Errorf("could not find stream URL on phim30 page")
|
||||||
var info VideoInfo
|
}
|
||||||
// yt-dlp dumps JSON. Unmarshal it.
|
|
||||||
// Note: yt-dlp JSON has many fields, we only map the ones in VideoInfo struct
|
// Build format selector
|
||||||
if err := json.Unmarshal(output, &info); err != nil {
|
formatSelector := "bestvideo+bestaudio/best"
|
||||||
return nil, fmt.Errorf("json parse error: %v", err)
|
if quality != "" {
|
||||||
}
|
height := strings.Replace(quality, "p", "", -1)
|
||||||
|
formatSelector = fmt.Sprintf("bestvideo[height<=%s]+bestaudio/best[height<=%s]/best", height, height)
|
||||||
// Post-process resolution if not directly available or custom logic needed
|
}
|
||||||
// In strict parsing, we might need a custom struct to catch 'height' and 'width' to form resolution
|
|
||||||
// allowing dynamic map parsing for simplicity:
|
args := []string{
|
||||||
var rawData map[string]interface{}
|
"--dump-json",
|
||||||
json.Unmarshal(output, &rawData)
|
"--no-playlist",
|
||||||
|
"--no-warnings",
|
||||||
if h, ok := rawData["height"].(float64); ok {
|
"--format", formatSelector,
|
||||||
info.Resolution = fmt.Sprintf("%dp", int(h))
|
url,
|
||||||
} else {
|
}
|
||||||
info.Resolution = "unknown"
|
|
||||||
}
|
// Check for local yt-dlp.exe
|
||||||
|
ytDlpCmd := "yt-dlp"
|
||||||
// Ensure StreamURL is populated (sometimes 'url' is the stream url)
|
// Only on windows for simplicity or check OS
|
||||||
if info.StreamURL == "" {
|
if _, err := os.Stat("yt-dlp.exe"); err == nil {
|
||||||
if u, ok := rawData["url"].(string); ok {
|
path, _ := filepath.Abs("yt-dlp.exe")
|
||||||
info.StreamURL = u
|
ytDlpCmd = path
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
cmd := exec.CommandContext(ctx, ytDlpCmd, args...)
|
||||||
return &info, nil
|
output, err := cmd.Output()
|
||||||
}
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("extraction failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var info VideoInfo
|
||||||
|
// yt-dlp dumps JSON. Unmarshal it.
|
||||||
|
// Note: yt-dlp JSON has many fields, we only map the ones in VideoInfo struct
|
||||||
|
if err := json.Unmarshal(output, &info); err != nil {
|
||||||
|
return nil, fmt.Errorf("json parse error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-process resolution if not directly available or custom logic needed
|
||||||
|
// In strict parsing, we might need a custom struct to catch 'height' and 'width' to form resolution
|
||||||
|
// allowing dynamic map parsing for simplicity:
|
||||||
|
var rawData map[string]interface{}
|
||||||
|
json.Unmarshal(output, &rawData)
|
||||||
|
|
||||||
|
if h, ok := rawData["height"].(float64); ok {
|
||||||
|
info.Resolution = fmt.Sprintf("%dp", int(h))
|
||||||
|
} else {
|
||||||
|
info.Resolution = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure StreamURL is populated (sometimes 'url' is the stream url)
|
||||||
|
if info.StreamURL == "" {
|
||||||
|
if u, ok := rawData["url"].(string); ok {
|
||||||
|
info.StreamURL = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,113 +1,116 @@
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/image/draw"
|
"golang.org/x/image/draw"
|
||||||
)
|
)
|
||||||
|
|
||||||
const CacheDir = "cache/images"
|
const CacheDir = "cache/images"
|
||||||
|
|
||||||
type ImageService struct {
|
type ImageService struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewImageService() *ImageService {
|
func NewImageService() *ImageService {
|
||||||
os.MkdirAll(CacheDir, 0755)
|
os.MkdirAll(CacheDir, 0755)
|
||||||
|
|
||||||
// Use custom transport to skip SSL verification
|
// Use custom transport to skip SSL verification
|
||||||
tr := &http.Transport{
|
tr := &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ImageService{
|
return &ImageService{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Transport: tr,
|
Transport: tr,
|
||||||
Timeout: 15 * time.Second,
|
Timeout: 15 * time.Second,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ImageService) GetProxiedImage(url string, width int) ([]byte, string, error) {
|
func (s *ImageService) GetProxiedImage(url string, width int) ([]byte, string, error) {
|
||||||
hash := md5.Sum([]byte(fmt.Sprintf("%s_%d", url, width)))
|
hash := md5.Sum([]byte(fmt.Sprintf("%s_%d", url, width)))
|
||||||
cacheKey := fmt.Sprintf("%x.jpg", hash)
|
cacheKey := fmt.Sprintf("%x.jpg", hash)
|
||||||
cachePath := filepath.Join(CacheDir, cacheKey)
|
cachePath := filepath.Join(CacheDir, cacheKey)
|
||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
if _, err := os.Stat(cachePath); err == nil {
|
if _, err := os.Stat(cachePath); err == nil {
|
||||||
data, err := os.ReadFile(cachePath)
|
data, err := os.ReadFile(cachePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return data, "image/jpeg", nil
|
return data, "image/jpeg", nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch with custom request to set headers
|
// Fetch with custom request to set headers
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
req.Header.Set("Referer", "https://ophim1.com/")
|
req.Header.Set("Referer", "https://ophim1.com/")
|
||||||
|
|
||||||
resp, err := s.client.Do(req)
|
resp, err := s.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
fmt.Printf("GetProxiedImage fetch error: %v\n", err)
|
||||||
}
|
return nil, "", err
|
||||||
defer resp.Body.Close()
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return nil, "", fmt.Errorf("image fetch failed: %d", resp.StatusCode)
|
if resp.StatusCode != 200 {
|
||||||
}
|
fmt.Printf("GetProxiedImage status error: %d for url: %s\n", resp.StatusCode, url)
|
||||||
|
return nil, "", fmt.Errorf("image fetch failed: %d", resp.StatusCode)
|
||||||
// Decode
|
}
|
||||||
var img image.Image
|
|
||||||
contentType := resp.Header.Get("Content-Type")
|
// Decode
|
||||||
|
var img image.Image
|
||||||
switch contentType {
|
contentType := resp.Header.Get("Content-Type")
|
||||||
case "image/jpeg":
|
|
||||||
img, err = jpeg.Decode(resp.Body)
|
switch contentType {
|
||||||
case "image/png":
|
case "image/jpeg":
|
||||||
img, err = png.Decode(resp.Body)
|
img, err = jpeg.Decode(resp.Body)
|
||||||
default:
|
case "image/png":
|
||||||
// Attempt agnostic decode
|
img, err = png.Decode(resp.Body)
|
||||||
img, _, err = image.Decode(resp.Body)
|
default:
|
||||||
}
|
// Attempt agnostic decode
|
||||||
|
img, _, err = image.Decode(resp.Body)
|
||||||
if err != nil {
|
}
|
||||||
return nil, "", fmt.Errorf("decode error: %v", err)
|
|
||||||
}
|
if err != nil {
|
||||||
|
fmt.Printf("GetProxiedImage decode error: %v for content-type: %s and url: %s\n", err, contentType, url)
|
||||||
// Resize if needed
|
return nil, "", fmt.Errorf("decode error: %v", err)
|
||||||
if width > 0 && img.Bounds().Dx() > width {
|
}
|
||||||
bounds := img.Bounds()
|
|
||||||
ratio := float64(width) / float64(bounds.Dx())
|
// Resize if needed
|
||||||
height := int(float64(bounds.Dy()) * ratio)
|
if width > 0 && img.Bounds().Dx() > width {
|
||||||
|
bounds := img.Bounds()
|
||||||
dst := image.NewRGBA(image.Rect(0, 0, width, height))
|
ratio := float64(width) / float64(bounds.Dx())
|
||||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil)
|
height := int(float64(bounds.Dy()) * ratio)
|
||||||
img = dst
|
|
||||||
}
|
dst := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||||
|
draw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil)
|
||||||
// Encode to JPEG
|
img = dst
|
||||||
var buf bytes.Buffer
|
}
|
||||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
|
|
||||||
return nil, "", fmt.Errorf("jpeg encode error: %v", err)
|
// Encode to JPEG
|
||||||
}
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
|
||||||
jpegData := buf.Bytes()
|
return nil, "", fmt.Errorf("jpeg encode error: %v", err)
|
||||||
|
}
|
||||||
// Write cache
|
|
||||||
os.WriteFile(cachePath, jpegData, 0644)
|
jpegData := buf.Bytes()
|
||||||
|
|
||||||
return jpegData, "image/jpeg", nil
|
// Write cache
|
||||||
}
|
os.WriteFile(cachePath, jpegData, 0644)
|
||||||
|
|
||||||
|
return jpegData, "image/jpeg", nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,137 +1,137 @@
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TMDBBaseURL = "https://api.themoviedb.org/3"
|
TMDBBaseURL = "https://api.themoviedb.org/3"
|
||||||
TMDBImageBaseURL = "https://image.tmdb.org/t/p"
|
TMDBImageBaseURL = "https://image.tmdb.org/t/p"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TMDBService struct {
|
type TMDBService struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
apiKey string
|
apiKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTMDBService() *TMDBService {
|
func NewTMDBService() *TMDBService {
|
||||||
return &TMDBService{
|
return &TMDBService{
|
||||||
client: &http.Client{Timeout: 10 * time.Second},
|
client: &http.Client{Timeout: 10 * time.Second},
|
||||||
apiKey: os.Getenv("TMDB_API_KEY"),
|
apiKey: os.Getenv("TMDB_API_KEY"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type TMDBMovieResult struct {
|
type TMDBMovieResult struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Overview string `json:"overview"`
|
Overview string `json:"overview"`
|
||||||
PosterPath string `json:"poster_path"`
|
PosterPath string `json:"poster_path"`
|
||||||
BackdropPath string `json:"backdrop_path"`
|
BackdropPath string `json:"backdrop_path"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
VoteAverage float64 `json:"vote_average"`
|
VoteAverage float64 `json:"vote_average"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TMDBSearchResponse struct {
|
type TMDBSearchResponse struct {
|
||||||
Results []TMDBMovieResult `json:"results"`
|
Results []TMDBMovieResult `json:"results"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TMDBMovieDetails struct {
|
type TMDBMovieDetails struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Overview string `json:"overview"`
|
Overview string `json:"overview"`
|
||||||
Runtime int `json:"runtime"`
|
Runtime int `json:"runtime"`
|
||||||
Budget int64 `json:"budget"`
|
Budget int64 `json:"budget"`
|
||||||
Revenue int64 `json:"revenue"`
|
Revenue int64 `json:"revenue"`
|
||||||
Tagline string `json:"tagline"`
|
Tagline string `json:"tagline"`
|
||||||
VoteAverage float64 `json:"vote_average"`
|
VoteAverage float64 `json:"vote_average"`
|
||||||
PosterPath string `json:"poster_path"`
|
PosterPath string `json:"poster_path"`
|
||||||
BackdropPath string `json:"backdrop_path"`
|
BackdropPath string `json:"backdrop_path"`
|
||||||
Credits struct {
|
Credits struct {
|
||||||
Cast []struct {
|
Cast []struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Character string `json:"character"`
|
Character string `json:"character"`
|
||||||
ProfilePath string `json:"profile_path"`
|
ProfilePath string `json:"profile_path"`
|
||||||
} `json:"cast"`
|
} `json:"cast"`
|
||||||
Crew []struct {
|
Crew []struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Job string `json:"job"`
|
Job string `json:"job"`
|
||||||
} `json:"crew"`
|
} `json:"crew"`
|
||||||
} `json:"credits"`
|
} `json:"credits"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TMDBService) SearchMovie(title string, year int) (*TMDBMovieResult, error) {
|
func (s *TMDBService) SearchMovie(title string, year int) (*TMDBMovieResult, error) {
|
||||||
if s.apiKey == "" {
|
if s.apiKey == "" {
|
||||||
return nil, fmt.Errorf("TMDB_API_KEY not set")
|
return nil, fmt.Errorf("TMDB_API_KEY not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("api_key", s.apiKey)
|
params.Add("api_key", s.apiKey)
|
||||||
params.Add("query", title)
|
params.Add("query", title)
|
||||||
params.Add("language", "en-US")
|
params.Add("language", "en-US")
|
||||||
if year > 0 {
|
if year > 0 {
|
||||||
params.Add("year", fmt.Sprintf("%d", year))
|
params.Add("year", fmt.Sprintf("%d", year))
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := s.client.Get(fmt.Sprintf("%s/search/movie?%s", TMDBBaseURL, params.Encode()))
|
resp, err := s.client.Get(fmt.Sprintf("%s/search/movie?%s", TMDBBaseURL, params.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode)
|
return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchResp TMDBSearchResponse
|
var searchResp TMDBSearchResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(searchResp.Results) > 0 {
|
if len(searchResp.Results) > 0 {
|
||||||
return &searchResp.Results[0], nil
|
return &searchResp.Results[0], nil
|
||||||
}
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TMDBService) GetMovieDetails(tmdbID int) (*TMDBMovieDetails, error) {
|
func (s *TMDBService) GetMovieDetails(tmdbID int) (*TMDBMovieDetails, error) {
|
||||||
if s.apiKey == "" {
|
if s.apiKey == "" {
|
||||||
return nil, fmt.Errorf("TMDB_API_KEY not set")
|
return nil, fmt.Errorf("TMDB_API_KEY not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("api_key", s.apiKey)
|
params.Add("api_key", s.apiKey)
|
||||||
params.Add("append_to_response", "credits")
|
params.Add("append_to_response", "credits")
|
||||||
params.Add("language", "en-US")
|
params.Add("language", "en-US")
|
||||||
|
|
||||||
resp, err := s.client.Get(fmt.Sprintf("%s/movie/%d?%s", TMDBBaseURL, tmdbID, params.Encode()))
|
resp, err := s.client.Get(fmt.Sprintf("%s/movie/%d?%s", TMDBBaseURL, tmdbID, params.Encode()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode)
|
return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
var details TMDBMovieDetails
|
var details TMDBMovieDetails
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&details); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&details); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &details, nil
|
return &details, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TMDBService) GetPosterURL(path string, size string) string {
|
func (s *TMDBService) GetPosterURL(path string, size string) string {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if size == "" {
|
if size == "" {
|
||||||
size = "w500"
|
size = "w500"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s/%s%s", TMDBImageBaseURL, size, path)
|
return fmt.Sprintf("%s/%s%s", TMDBImageBaseURL, size, path)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
deploy.ps1
|
|
@ -1,31 +0,0 @@
|
||||||
# Streamflow Deployment Script
|
|
||||||
# Automates building and pushing Docker images to registries
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
|
|
||||||
Write-Host "=============================" -ForegroundColor Cyan
|
|
||||||
Write-Host " Streamflow Deployer " -ForegroundColor Cyan
|
|
||||||
Write-Host "=============================" -ForegroundColor Cyan
|
|
||||||
|
|
||||||
# 1. Build
|
|
||||||
Write-Host "`n[1/3] Building Docker Image..." -ForegroundColor White
|
|
||||||
docker build -t streamflow:latest .
|
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Error "Build failed"; exit 1 }
|
|
||||||
Write-Host " -> Build successful" -ForegroundColor Green
|
|
||||||
|
|
||||||
# 2. Push to Docker Hub
|
|
||||||
Write-Host "`n[2/3] Pushing to Docker Hub..." -ForegroundColor White
|
|
||||||
docker tag streamflow:latest vndangkhoa/streamflow:latest
|
|
||||||
docker push vndangkhoa/streamflow:latest
|
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Warning "Docker Hub push failed. Check your login." }
|
|
||||||
else { Write-Host " -> Pushed to Docker Hub" -ForegroundColor Green }
|
|
||||||
|
|
||||||
# 3. Push to Private Registry
|
|
||||||
Write-Host "`n[3/3] Pushing to Private Registry..." -ForegroundColor White
|
|
||||||
docker tag streamflow:latest git.khoavo.myds.me/vndangkhoa/kv-streamflow:latest
|
|
||||||
docker push git.khoavo.myds.me/vndangkhoa/kv-streamflow:latest
|
|
||||||
if ($LASTEXITCODE -ne 0) { Write-Warning "Private Registry push failed. Check VPN/Login." }
|
|
||||||
else { Write-Host " -> Pushed to Private Registry" -ForegroundColor Green }
|
|
||||||
|
|
||||||
Write-Host "`nDeployment Complete!" -ForegroundColor Magenta
|
|
||||||
Start-Sleep -Seconds 5
|
|
||||||
|
|
@ -2,7 +2,7 @@ version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
streamflow:
|
streamflow:
|
||||||
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3.9
|
image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v6
|
||||||
container_name: streamflow
|
container_name: streamflow
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -12,10 +12,11 @@ services:
|
||||||
- PORT=8000
|
- PORT=8000
|
||||||
- TZ=Asia/Ho_Chi_Minh
|
- TZ=Asia/Ho_Chi_Minh
|
||||||
volumes:
|
volumes:
|
||||||
|
# Synology: Use relative path for data persistence
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD", "wget", "-q", "--spider", "http://localhost:8000/api/health" ]
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
|
||||||
1271
frontend-react/package-lock.json
generated
|
|
@ -1,6 +1,6 @@
|
||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
'@tailwindcss/postcss': {},
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||