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)
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend-react/package*.json ./
|
||||
RUN npm install
|
||||
COPY frontend-react/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Image (Backend)
|
||||
FROM golang:1.24-alpine AS backend-builder
|
||||
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/ .
|
||||
# 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
|
||||
FROM alpine:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies (sqlite + yt-dlp for video extraction fallback)
|
||||
RUN apk add --no-cache sqlite ca-certificates tzdata python3 py3-pip && \
|
||||
pip3 install --break-system-packages yt-dlp
|
||||
|
||||
# Copy backend binary
|
||||
COPY --from=backend-builder /app/backend/server .
|
||||
|
||||
# Copy frontend build to the expected static directory
|
||||
# The backend expects ../frontend-react/dist relative to itself, or we configure it.
|
||||
# Let's align with the standard deployment structure: /app/server and /app/dist
|
||||
COPY --from=frontend-builder /app/frontend/dist ./dist
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p data
|
||||
|
||||
# Environment variables
|
||||
ENV PORT=8000
|
||||
ENV DATABASE_URL=/app/data/streamflow.db
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Start server
|
||||
CMD ["./server"]
|
||||
# Stage 1: Build Frontend
|
||||
FROM --platform=linux/amd64 node:20-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend-react/package*.json ./
|
||||
RUN npm install
|
||||
COPY frontend-react/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Backend for linux/amd64
|
||||
FROM --platform=linux/amd64 golang:1.24-alpine AS backend-builder
|
||||
WORKDIR /app/backend
|
||||
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY backend/ .
|
||||
# Build static binary for Linux amd64
|
||||
RUN CGO_ENABLED=0 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
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache sqlite ca-certificates tzdata
|
||||
|
||||
# Copy backend binary
|
||||
COPY --from=backend-builder /app/backend/server .
|
||||
|
||||
# Copy frontend build to the expected static directory
|
||||
COPY --from=frontend-builder /app/frontend/dist ./dist
|
||||
|
||||
# Create data directory for SQLite database
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Environment variables
|
||||
ENV PORT=8000
|
||||
ENV DATABASE_URL=/app/data/streamflow.db
|
||||
ENV TZ=Asia/Ho_Chi_Minh
|
||||
|
||||
# Expose port
|
||||
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.
|
||||
|
||||
|
|
@ -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
|
||||
- **Android TV** - Native TV app with D-pad controls and 10s skip
|
||||
- **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)
|
||||
|
||||
## Tech Stack
|
||||
|
|
@ -23,21 +24,45 @@ A high-performance video streaming web application with a pure Go backend and mo
|
|||
|
||||
## 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
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
streamflow:
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3.9
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v6
|
||||
container_name: streamflow
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
- "3478:8000"
|
||||
environment:
|
||||
- DATABASE_URL=/app/data/streamflow.db
|
||||
- PORT=8000
|
||||
- TZ=Asia/Ho_Chi_Minh
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
|
|
@ -51,7 +76,14 @@ services:
|
|||
```
|
||||
|
||||
```bash
|
||||
# Login to registry first
|
||||
docker login git.khoavo.myds.me -u vndangkhoa -p Thieugia19
|
||||
|
||||
# Start container
|
||||
docker-compose up -d
|
||||
|
||||
# Check logs
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
Access at: `http://YOUR_NAS_IP:3478`
|
||||
|
|
@ -119,7 +151,26 @@ Streamflow/
|
|||
|
||||
## 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
|
||||
- Verified D-pad navigation on Android TV app
|
||||
|
||||
|
|
|
|||
BIN
android-tv/adb_logs.txt
Normal file
|
|
@ -1,88 +1,88 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.streamflow.tv"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.streamflow.tv"
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionCode = 37
|
||||
versionName = "3.7.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.8"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose for TV
|
||||
implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
|
||||
implementation("androidx.tv:tv-material:1.0.0")
|
||||
|
||||
// Core Compose
|
||||
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||
|
||||
// ExoPlayer (Media3)
|
||||
implementation("androidx.media3:media3-exoplayer:1.2.1")
|
||||
implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
|
||||
implementation("androidx.media3:media3-ui:1.2.1")
|
||||
implementation("androidx.media3:media3-session:1.2.1")
|
||||
|
||||
// Networking
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
|
||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
|
||||
// Image loading
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
|
||||
// DataStore
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
|
||||
// Core Android TV
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.leanback:leanback:1.0.0")
|
||||
|
||||
// Debug
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
}
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.streamflow.tv"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.streamflow.tv"
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionCode = 37
|
||||
versionName = "3.7.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.8"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose for TV
|
||||
implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
|
||||
implementation("androidx.tv:tv-material:1.0.0")
|
||||
|
||||
// Core Compose
|
||||
implementation(platform("androidx.compose:compose-bom:2024.01.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||
|
||||
// ExoPlayer (Media3)
|
||||
implementation("androidx.media3:media3-exoplayer:1.2.1")
|
||||
implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
|
||||
implementation("androidx.media3:media3-ui:1.2.1")
|
||||
implementation("androidx.media3:media3-session:1.2.1")
|
||||
|
||||
// Networking
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
|
||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
|
||||
// Image loading
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
|
||||
// DataStore
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
|
||||
// Core Android TV
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.leanback:leanback:1.0.0")
|
||||
|
||||
// Debug
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
}
|
||||
|
|
|
|||
108
android-tv/app/proguard-rules.pro
vendored
|
|
@ -1,54 +1,54 @@
|
|||
# ProGuard rules for StreamFlow TV
|
||||
|
||||
# Keep all app classes (safety net)
|
||||
-keep class com.streamflow.tv.** { *; }
|
||||
-keepclassmembers class com.streamflow.tv.** { *; }
|
||||
|
||||
# Moshi
|
||||
-keep class com.squareup.moshi.** { *; }
|
||||
-keepclassmembers class * {
|
||||
@com.squareup.moshi.Json <fields>;
|
||||
}
|
||||
-keepclassmembers class * {
|
||||
@com.squareup.moshi.JsonClass <fields>;
|
||||
}
|
||||
|
||||
# Kotlin Metadata (critical for Moshi reflection adapter)
|
||||
-keep class kotlin.Metadata { *; }
|
||||
-keepattributes RuntimeVisibleAnnotations
|
||||
-keepattributes RuntimeInvisibleAnnotations
|
||||
-keepattributes *Annotation*
|
||||
|
||||
# Retrofit
|
||||
-dontwarn retrofit2.**
|
||||
-keep class retrofit2.** { *; }
|
||||
-keepattributes Signature
|
||||
-keepattributes Exceptions
|
||||
-keepclassmembers,allowshrinking,allowobfuscation interface * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
|
||||
# OkHttp
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-keep class okhttp3.** { *; }
|
||||
-keep class okio.** { *; }
|
||||
|
||||
# Kotlin Coroutines
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
-keepclassmembers class kotlinx.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
|
||||
# Coil
|
||||
-dontwarn coil.**
|
||||
-keep class coil.** { *; }
|
||||
|
||||
# AndroidX Compose
|
||||
-keep class androidx.compose.** { *; }
|
||||
-dontwarn androidx.compose.**
|
||||
|
||||
# ExoPlayer / Media3
|
||||
-keep class androidx.media3.** { *; }
|
||||
-dontwarn androidx.media3.**
|
||||
# ProGuard rules for StreamFlow TV
|
||||
|
||||
# Keep all app classes (safety net)
|
||||
-keep class com.streamflow.tv.** { *; }
|
||||
-keepclassmembers class com.streamflow.tv.** { *; }
|
||||
|
||||
# Moshi
|
||||
-keep class com.squareup.moshi.** { *; }
|
||||
-keepclassmembers class * {
|
||||
@com.squareup.moshi.Json <fields>;
|
||||
}
|
||||
-keepclassmembers class * {
|
||||
@com.squareup.moshi.JsonClass <fields>;
|
||||
}
|
||||
|
||||
# Kotlin Metadata (critical for Moshi reflection adapter)
|
||||
-keep class kotlin.Metadata { *; }
|
||||
-keepattributes RuntimeVisibleAnnotations
|
||||
-keepattributes RuntimeInvisibleAnnotations
|
||||
-keepattributes *Annotation*
|
||||
|
||||
# Retrofit
|
||||
-dontwarn retrofit2.**
|
||||
-keep class retrofit2.** { *; }
|
||||
-keepattributes Signature
|
||||
-keepattributes Exceptions
|
||||
-keepclassmembers,allowshrinking,allowobfuscation interface * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
|
||||
# OkHttp
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-keep class okhttp3.** { *; }
|
||||
-keep class okio.** { *; }
|
||||
|
||||
# Kotlin Coroutines
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
-keepclassmembers class kotlinx.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
|
||||
# Coil
|
||||
-dontwarn coil.**
|
||||
-keep class coil.** { *; }
|
||||
|
||||
# AndroidX Compose
|
||||
-keep class androidx.compose.** { *; }
|
||||
-dontwarn androidx.compose.**
|
||||
|
||||
# ExoPlayer / Media3
|
||||
-keep class androidx.media3.** { *; }
|
||||
-dontwarn androidx.media3.**
|
||||
|
|
|
|||
|
|
@ -1,43 +1,43 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".StreamFlowApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:banner="@drawable/app_banner"
|
||||
android:theme="@style/Theme.StreamFlowTV"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:screenOrientation="landscape">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="streamflow" android:host="player" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".StreamFlowApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:banner="@drawable/app_banner"
|
||||
android:theme="@style/Theme.StreamFlowTV"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
android:screenOrientation="landscape">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="streamflow" android:host="player" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -1,172 +1,172 @@
|
|||
package com.streamflow.tv
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.navArgument
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.repository.UserDataRepository
|
||||
import com.streamflow.tv.ui.components.SideNavRail
|
||||
import com.streamflow.tv.ui.screens.*
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTvTheme
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.d("MainActivity", "onCreate started")
|
||||
setContent {
|
||||
StreamFlowTvApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StreamFlowTvApp() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val userRepo = remember { UserDataRepository(context) }
|
||||
val navController = rememberNavController()
|
||||
|
||||
var currentTheme by remember { mutableStateOf("default") }
|
||||
var selectedNavId by remember { mutableStateOf("home") }
|
||||
|
||||
// Load persisted settings
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
currentTheme = userRepo.theme.first()
|
||||
package com.streamflow.tv
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.navArgument
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.repository.UserDataRepository
|
||||
import com.streamflow.tv.ui.components.SideNavRail
|
||||
import com.streamflow.tv.ui.screens.*
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTvTheme
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.d("MainActivity", "onCreate started")
|
||||
setContent {
|
||||
StreamFlowTvApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StreamFlowTvApp() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val userRepo = remember { UserDataRepository(context) }
|
||||
val navController = rememberNavController()
|
||||
|
||||
var currentTheme by remember { mutableStateOf("default") }
|
||||
var selectedNavId by remember { mutableStateOf("home") }
|
||||
|
||||
// Load persisted settings
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
currentTheme = userRepo.theme.first()
|
||||
val serverUrl = userRepo.serverUrl.first()
|
||||
/*if (serverUrl.isNotBlank()) {
|
||||
if (serverUrl.isNotBlank()) {
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Main content
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "home"
|
||||
) {
|
||||
composable("home") {
|
||||
HomeScreen(
|
||||
onMovieClick = { slug ->
|
||||
navController.navigate("detail/$slug")
|
||||
},
|
||||
userDataRepository = userRepo
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
"home/{category}",
|
||||
arguments = listOf(navArgument("category") { type = NavType.StringType })
|
||||
) { entry ->
|
||||
HomeScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") },
|
||||
category = entry.arguments?.getString("category"),
|
||||
userDataRepository = userRepo
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
"detail/{slug}",
|
||||
arguments = listOf(navArgument("slug") { type = NavType.StringType })
|
||||
) { entry ->
|
||||
val slug = entry.arguments?.getString("slug") ?: return@composable
|
||||
DetailScreen(
|
||||
slug = slug,
|
||||
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
"player/{slug}/{episode}",
|
||||
arguments = listOf(
|
||||
navArgument("slug") { type = NavType.StringType },
|
||||
navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
|
||||
),
|
||||
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) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Main content
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "home"
|
||||
) {
|
||||
composable("home") {
|
||||
HomeScreen(
|
||||
onMovieClick = { slug ->
|
||||
navController.navigate("detail/$slug")
|
||||
},
|
||||
userDataRepository = userRepo
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
"home/{category}",
|
||||
arguments = listOf(navArgument("category") { type = NavType.StringType })
|
||||
) { entry ->
|
||||
HomeScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") },
|
||||
category = entry.arguments?.getString("category"),
|
||||
userDataRepository = userRepo
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
"detail/{slug}",
|
||||
arguments = listOf(navArgument("slug") { type = NavType.StringType })
|
||||
) { entry ->
|
||||
val slug = entry.arguments?.getString("slug") ?: return@composable
|
||||
DetailScreen(
|
||||
slug = slug,
|
||||
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
"player/{slug}/{episode}",
|
||||
arguments = listOf(
|
||||
navArgument("slug") { type = NavType.StringType },
|
||||
navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
|
||||
),
|
||||
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
|
||||
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
|
||||
class StreamFlowApp : Application(), ImageLoaderFactory {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.25)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(this.cacheDir.resolve("image_cache"))
|
||||
.maxSizePercent(0.02)
|
||||
.build()
|
||||
}
|
||||
.respectCacheHeaders(false) // Often needed for some CDNs
|
||||
.build()
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv
|
||||
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
|
||||
class StreamFlowApp : Application(), ImageLoaderFactory {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.25)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(this.cacheDir.resolve("image_cache"))
|
||||
.maxSizePercent(0.02)
|
||||
.build()
|
||||
}
|
||||
.respectCacheHeaders(false) // Often needed for some CDNs
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +1,71 @@
|
|||
package com.streamflow.tv.data.api
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object ApiClient {
|
||||
|
||||
// Default base URL for testing
|
||||
// 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:3478/"
|
||||
|
||||
var baseUrl: String
|
||||
get() = _baseUrl
|
||||
set(value) {
|
||||
_baseUrl = if (value.endsWith("/")) value else "$value/"
|
||||
synchronized(this) {
|
||||
_api = null // Reset to rebuild
|
||||
}
|
||||
}
|
||||
|
||||
private val moshi: Moshi = Moshi.Builder()
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
|
||||
private val userAgentInterceptor = Interceptor { chain ->
|
||||
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")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}
|
||||
|
||||
private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.addInterceptor(userAgentInterceptor)
|
||||
.addInterceptor(
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.HEADERS
|
||||
}
|
||||
)
|
||||
.build()
|
||||
|
||||
private var _api: StreamFlowApi? = null
|
||||
|
||||
val api: StreamFlowApi
|
||||
get() {
|
||||
return synchronized(this) {
|
||||
if (_api == null) {
|
||||
_api = Retrofit.Builder()
|
||||
.baseUrl(_baseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
.create(StreamFlowApi::class.java)
|
||||
}
|
||||
_api!!
|
||||
}
|
||||
}
|
||||
|
||||
fun imageProxyUrl(url: String, width: Int = 400): String {
|
||||
val base = _baseUrl.removeSuffix("/")
|
||||
return "$base/api/images/proxy?url=${java.net.URLEncoder.encode(url, "UTF-8")}&width=$width"
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.data.api
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object ApiClient {
|
||||
|
||||
// Default base URL for testing
|
||||
// Change this to your production API when ready
|
||||
// private var _baseUrl: String = "http://10.0.2.2:8000/"
|
||||
private var _baseUrl: String = "https://nf.khoavo.myds.me/"
|
||||
|
||||
var baseUrl: String
|
||||
get() = _baseUrl
|
||||
set(value) {
|
||||
_baseUrl = if (value.endsWith("/")) value else "$value/"
|
||||
synchronized(this) {
|
||||
_api = null // Reset to rebuild
|
||||
}
|
||||
}
|
||||
|
||||
private val moshi: Moshi = Moshi.Builder()
|
||||
.addLast(KotlinJsonAdapterFactory())
|
||||
.build()
|
||||
|
||||
private val userAgentInterceptor = Interceptor { chain ->
|
||||
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")
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}
|
||||
|
||||
private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.addInterceptor(userAgentInterceptor)
|
||||
.addInterceptor(
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.HEADERS
|
||||
}
|
||||
)
|
||||
.build()
|
||||
|
||||
private var _api: StreamFlowApi? = null
|
||||
|
||||
val api: StreamFlowApi
|
||||
get() {
|
||||
return synchronized(this) {
|
||||
if (_api == null) {
|
||||
_api = Retrofit.Builder()
|
||||
.baseUrl(_baseUrl)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
.create(StreamFlowApi::class.java)
|
||||
}
|
||||
_api!!
|
||||
}
|
||||
}
|
||||
|
||||
fun imageProxyUrl(url: String, width: Int = 400): String {
|
||||
val base = _baseUrl.removeSuffix("/")
|
||||
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
|
||||
|
||||
import com.streamflow.tv.data.model.*
|
||||
import retrofit2.http.*
|
||||
|
||||
interface StreamFlowApi {
|
||||
|
||||
@GET("api/videos/home")
|
||||
suspend fun getHomeVideos(
|
||||
@Query("category") category: String? = null,
|
||||
@Query("page") page: Int = 1
|
||||
): List<Movie>
|
||||
|
||||
@GET("api/videos/search")
|
||||
suspend fun searchVideos(
|
||||
@Query("q") query: String,
|
||||
@Query("page") page: Int = 1
|
||||
): List<Movie>
|
||||
|
||||
@GET("api/videos/{slug}")
|
||||
suspend fun getMovieDetail(
|
||||
@Path("slug") slug: String
|
||||
): MovieDetailResponse
|
||||
|
||||
@POST("api/extract")
|
||||
suspend fun extractVideo(
|
||||
@Body request: ExtractRequest
|
||||
): VideoSource
|
||||
|
||||
@GET("api/categories/genres")
|
||||
suspend fun getGenres(): List<Category>
|
||||
|
||||
@GET("api/categories/countries")
|
||||
suspend fun getCountries(): List<Category>
|
||||
}
|
||||
package com.streamflow.tv.data.api
|
||||
|
||||
import com.streamflow.tv.data.model.*
|
||||
import retrofit2.http.*
|
||||
|
||||
interface StreamFlowApi {
|
||||
|
||||
@GET("api/videos/home")
|
||||
suspend fun getHomeVideos(
|
||||
@Query("category") category: String? = null,
|
||||
@Query("page") page: Int = 1
|
||||
): List<Movie>
|
||||
|
||||
@GET("api/videos/search")
|
||||
suspend fun searchVideos(
|
||||
@Query("q") query: String,
|
||||
@Query("page") page: Int = 1
|
||||
): List<Movie>
|
||||
|
||||
@GET("api/videos/{slug}")
|
||||
suspend fun getMovieDetail(
|
||||
@Path("slug") slug: String
|
||||
): MovieDetailResponse
|
||||
|
||||
@POST("api/extract")
|
||||
suspend fun extractVideo(
|
||||
@Body request: ExtractRequest
|
||||
): VideoSource
|
||||
|
||||
@GET("api/categories/genres")
|
||||
suspend fun getGenres(): List<Category>
|
||||
|
||||
@GET("api/categories/countries")
|
||||
suspend fun getCountries(): List<Category>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,113 +1,113 @@
|
|||
package com.streamflow.tv.data.model
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class Movie(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
@Json(name = "original_title") val originalTitle: String? = null,
|
||||
val slug: String = "",
|
||||
val thumbnail: String = "",
|
||||
val backdrop: String? = null,
|
||||
val quality: String? = null,
|
||||
val year: Int? = null,
|
||||
val category: String = "",
|
||||
val time: String? = null,
|
||||
val lang: String? = null,
|
||||
val director: String? = null,
|
||||
val cast: List<String>? = null,
|
||||
val provider: String? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class MovieDetail(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
@Json(name = "original_title") val originalTitle: String? = null,
|
||||
val slug: String = "",
|
||||
val thumbnail: String = "",
|
||||
val backdrop: String? = null,
|
||||
val quality: String? = null,
|
||||
val year: Int? = null,
|
||||
val category: String = "",
|
||||
val description: String = "",
|
||||
val rating: String? = null,
|
||||
val duration: Int? = null,
|
||||
val genre: String? = null,
|
||||
val director: String? = null,
|
||||
val country: String? = null,
|
||||
val cast: List<String>? = null,
|
||||
val provider: String? = null,
|
||||
val episodes: List<Episode>? = null
|
||||
) {
|
||||
fun toMovie(): Movie = Movie(
|
||||
id = id,
|
||||
title = title,
|
||||
originalTitle = originalTitle,
|
||||
slug = slug,
|
||||
thumbnail = thumbnail,
|
||||
backdrop = backdrop,
|
||||
quality = quality,
|
||||
year = year,
|
||||
category = category,
|
||||
director = director,
|
||||
cast = cast,
|
||||
provider = provider
|
||||
)
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class Episode(
|
||||
val number: Int = 0,
|
||||
val title: String = "",
|
||||
val url: String = ""
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class VideoSource(
|
||||
@Json(name = "stream_url") val streamUrl: String = "",
|
||||
val resolution: String = "",
|
||||
@Json(name = "format_id") val formatId: String = ""
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class Category(
|
||||
val name: String = "",
|
||||
val slug: String = ""
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class HomeResponse(
|
||||
val items: List<Movie> = emptyList(),
|
||||
val totalPages: Int = 1,
|
||||
val currentPage: Int = 1
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class ExtractRequest(
|
||||
val url: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class MovieDetailResponse(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
@Json(name = "original_title") val originalTitle: String? = null,
|
||||
val slug: String = "",
|
||||
val thumbnail: String = "",
|
||||
val backdrop: String? = null,
|
||||
val quality: String? = null,
|
||||
val year: Int? = null,
|
||||
val category: String = "",
|
||||
val description: String = "",
|
||||
val rating: String? = null,
|
||||
val duration: Int? = null,
|
||||
val genre: String? = null,
|
||||
val director: String? = null,
|
||||
val country: String? = null,
|
||||
val cast: List<String>? = null,
|
||||
val episodes: List<Episode>? = null
|
||||
)
|
||||
|
||||
package com.streamflow.tv.data.model
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class Movie(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
@Json(name = "original_title") val originalTitle: String? = null,
|
||||
val slug: String = "",
|
||||
val thumbnail: String = "",
|
||||
val backdrop: String? = null,
|
||||
val quality: String? = null,
|
||||
val year: Int? = null,
|
||||
val category: String = "",
|
||||
val time: String? = null,
|
||||
val lang: String? = null,
|
||||
val director: String? = null,
|
||||
val cast: List<String>? = null,
|
||||
val provider: String? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class MovieDetail(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
@Json(name = "original_title") val originalTitle: String? = null,
|
||||
val slug: String = "",
|
||||
val thumbnail: String = "",
|
||||
val backdrop: String? = null,
|
||||
val quality: String? = null,
|
||||
val year: Int? = null,
|
||||
val category: String = "",
|
||||
val description: String = "",
|
||||
val rating: String? = null,
|
||||
val duration: Int? = null,
|
||||
val genre: String? = null,
|
||||
val director: String? = null,
|
||||
val country: String? = null,
|
||||
val cast: List<String>? = null,
|
||||
val provider: String? = null,
|
||||
val episodes: List<Episode>? = null
|
||||
) {
|
||||
fun toMovie(): Movie = Movie(
|
||||
id = id,
|
||||
title = title,
|
||||
originalTitle = originalTitle,
|
||||
slug = slug,
|
||||
thumbnail = thumbnail,
|
||||
backdrop = backdrop,
|
||||
quality = quality,
|
||||
year = year,
|
||||
category = category,
|
||||
director = director,
|
||||
cast = cast,
|
||||
provider = provider
|
||||
)
|
||||
}
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class Episode(
|
||||
val number: Int = 0,
|
||||
val title: String = "",
|
||||
val url: String = ""
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class VideoSource(
|
||||
@Json(name = "stream_url") val streamUrl: String = "",
|
||||
val resolution: String = "",
|
||||
@Json(name = "format_id") val formatId: String = ""
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class Category(
|
||||
val name: String = "",
|
||||
val slug: String = ""
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class HomeResponse(
|
||||
val items: List<Movie> = emptyList(),
|
||||
val totalPages: Int = 1,
|
||||
val currentPage: Int = 1
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class ExtractRequest(
|
||||
val url: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = false)
|
||||
data class MovieDetailResponse(
|
||||
val id: String = "",
|
||||
val title: String = "",
|
||||
@Json(name = "original_title") val originalTitle: String? = null,
|
||||
val slug: String = "",
|
||||
val thumbnail: String = "",
|
||||
val backdrop: String? = null,
|
||||
val quality: String? = null,
|
||||
val year: Int? = null,
|
||||
val category: String = "",
|
||||
val description: String = "",
|
||||
val rating: String? = null,
|
||||
val duration: Int? = null,
|
||||
val genre: String? = null,
|
||||
val director: String? = null,
|
||||
val country: String? = null,
|
||||
val cast: List<String>? = null,
|
||||
val episodes: List<Episode>? = null
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,60 +1,60 @@
|
|||
package com.streamflow.tv.data.repository
|
||||
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.*
|
||||
|
||||
class MovieRepository {
|
||||
|
||||
private val api get() = ApiClient.api
|
||||
|
||||
suspend fun getHomeVideos(category: String? = null, page: Int = 1): HomeResponse {
|
||||
val list = api.getHomeVideos(category, page)
|
||||
android.util.Log.e("MovieRepo", "getHomeVideos($category): Received ${list.size} items")
|
||||
return HomeResponse(items = list, totalPages = 10, currentPage = page)
|
||||
}
|
||||
|
||||
suspend fun searchVideos(query: String, page: Int = 1): HomeResponse {
|
||||
val list = api.searchVideos(query, page)
|
||||
android.util.Log.e("MovieRepo", "searchVideos($query): Received ${list.size} items")
|
||||
return HomeResponse(items = list, totalPages = 1, currentPage = page)
|
||||
}
|
||||
|
||||
suspend fun getMovieDetail(slug: String): MovieDetail {
|
||||
val response = api.getMovieDetail(slug)
|
||||
|
||||
// API returns a flat list of episodes
|
||||
val episodes = response.episodes ?: emptyList()
|
||||
|
||||
return MovieDetail(
|
||||
id = response.id,
|
||||
title = response.title,
|
||||
originalTitle = response.originalTitle,
|
||||
slug = response.slug,
|
||||
thumbnail = response.thumbnail,
|
||||
backdrop = response.backdrop,
|
||||
quality = response.quality,
|
||||
year = response.year,
|
||||
category = response.category,
|
||||
description = response.description,
|
||||
rating = response.rating,
|
||||
duration = response.duration,
|
||||
genre = response.genre,
|
||||
director = response.director,
|
||||
country = response.country,
|
||||
cast = response.cast,
|
||||
episodes = episodes
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun extractVideo(url: String): VideoSource {
|
||||
return api.extractVideo(ExtractRequest(url))
|
||||
}
|
||||
|
||||
suspend fun getGenres(): List<Category> {
|
||||
return api.getGenres()
|
||||
}
|
||||
|
||||
suspend fun getCountries(): List<Category> {
|
||||
return api.getCountries()
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.data.repository
|
||||
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.*
|
||||
|
||||
class MovieRepository {
|
||||
|
||||
private val api get() = ApiClient.api
|
||||
|
||||
suspend fun getHomeVideos(category: String? = null, page: Int = 1): HomeResponse {
|
||||
val list = api.getHomeVideos(category, page)
|
||||
android.util.Log.e("MovieRepo", "getHomeVideos($category): Received ${list.size} items")
|
||||
return HomeResponse(items = list, totalPages = 10, currentPage = page)
|
||||
}
|
||||
|
||||
suspend fun searchVideos(query: String, page: Int = 1): HomeResponse {
|
||||
val list = api.searchVideos(query, page)
|
||||
android.util.Log.e("MovieRepo", "searchVideos($query): Received ${list.size} items")
|
||||
return HomeResponse(items = list, totalPages = 1, currentPage = page)
|
||||
}
|
||||
|
||||
suspend fun getMovieDetail(slug: String): MovieDetail {
|
||||
val response = api.getMovieDetail(slug)
|
||||
|
||||
// API returns a flat list of episodes
|
||||
val episodes = response.episodes ?: emptyList()
|
||||
|
||||
return MovieDetail(
|
||||
id = response.id,
|
||||
title = response.title,
|
||||
originalTitle = response.originalTitle,
|
||||
slug = response.slug,
|
||||
thumbnail = response.thumbnail,
|
||||
backdrop = response.backdrop,
|
||||
quality = response.quality,
|
||||
year = response.year,
|
||||
category = response.category,
|
||||
description = response.description,
|
||||
rating = response.rating,
|
||||
duration = response.duration,
|
||||
genre = response.genre,
|
||||
director = response.director,
|
||||
country = response.country,
|
||||
cast = response.cast,
|
||||
episodes = episodes
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun extractVideo(url: String): VideoSource {
|
||||
return api.extractVideo(ExtractRequest(url))
|
||||
}
|
||||
|
||||
suspend fun getGenres(): List<Category> {
|
||||
return api.getGenres()
|
||||
}
|
||||
|
||||
suspend fun getCountries(): List<Category> {
|
||||
return api.getCountries()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,103 +1,103 @@
|
|||
package com.streamflow.tv.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.*
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_data")
|
||||
|
||||
class UserDataRepository(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private val MY_LIST_KEY = stringPreferencesKey("my_list")
|
||||
private val WATCH_HISTORY_KEY = stringPreferencesKey("watch_history")
|
||||
private val THEME_KEY = stringPreferencesKey("theme")
|
||||
private val SERVER_URL_KEY = stringPreferencesKey("server_url")
|
||||
|
||||
private const val MAX_HISTORY = 50
|
||||
}
|
||||
|
||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||
private val movieListType = Types.newParameterizedType(List::class.java, Movie::class.java)
|
||||
private val movieListAdapter = moshi.adapter<List<Movie>>(movieListType)
|
||||
|
||||
// --- My List ---
|
||||
|
||||
val myList: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
|
||||
val json = prefs[MY_LIST_KEY] ?: "[]"
|
||||
movieListAdapter.fromJson(json) ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun addToMyList(movie: Movie) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||
if (current.none { it.slug == movie.slug }) {
|
||||
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current + movie)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeFromMyList(slug: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current.filter { it.slug != slug })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isInMyList(slug: String): Boolean {
|
||||
var found = false
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||
found = current.any { it.slug == slug }
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// --- Watch History ---
|
||||
|
||||
val watchHistory: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
|
||||
val json = prefs[WATCH_HISTORY_KEY] ?: "[]"
|
||||
movieListAdapter.fromJson(json) ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun addToHistory(movie: Movie) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[WATCH_HISTORY_KEY] ?: "[]")?.toMutableList() ?: mutableListOf()
|
||||
current.removeAll { it.slug == movie.slug }
|
||||
current.add(0, movie) // Most recent first
|
||||
val trimmed = current.take(MAX_HISTORY)
|
||||
prefs[WATCH_HISTORY_KEY] = movieListAdapter.toJson(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Theme ---
|
||||
|
||||
val theme: Flow<String> = context.dataStore.data.map { prefs ->
|
||||
prefs[THEME_KEY] ?: "default"
|
||||
}
|
||||
|
||||
suspend fun setTheme(theme: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[THEME_KEY] = theme
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server URL ---
|
||||
|
||||
val serverUrl: Flow<String> = context.dataStore.data.map { prefs ->
|
||||
prefs[SERVER_URL_KEY] ?: "https://nf.khoavo.myds.me"
|
||||
}
|
||||
|
||||
suspend fun setServerUrl(url: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[SERVER_URL_KEY] = url
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.*
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_data")
|
||||
|
||||
class UserDataRepository(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private val MY_LIST_KEY = stringPreferencesKey("my_list")
|
||||
private val WATCH_HISTORY_KEY = stringPreferencesKey("watch_history")
|
||||
private val THEME_KEY = stringPreferencesKey("theme")
|
||||
private val SERVER_URL_KEY = stringPreferencesKey("server_url")
|
||||
|
||||
private const val MAX_HISTORY = 50
|
||||
}
|
||||
|
||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||
private val movieListType = Types.newParameterizedType(List::class.java, Movie::class.java)
|
||||
private val movieListAdapter = moshi.adapter<List<Movie>>(movieListType)
|
||||
|
||||
// --- My List ---
|
||||
|
||||
val myList: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
|
||||
val json = prefs[MY_LIST_KEY] ?: "[]"
|
||||
movieListAdapter.fromJson(json) ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun addToMyList(movie: Movie) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||
if (current.none { it.slug == movie.slug }) {
|
||||
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current + movie)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeFromMyList(slug: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current.filter { it.slug != slug })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isInMyList(slug: String): Boolean {
|
||||
var found = false
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
|
||||
found = current.any { it.slug == slug }
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// --- Watch History ---
|
||||
|
||||
val watchHistory: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
|
||||
val json = prefs[WATCH_HISTORY_KEY] ?: "[]"
|
||||
movieListAdapter.fromJson(json) ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun addToHistory(movie: Movie) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val current = movieListAdapter.fromJson(prefs[WATCH_HISTORY_KEY] ?: "[]")?.toMutableList() ?: mutableListOf()
|
||||
current.removeAll { it.slug == movie.slug }
|
||||
current.add(0, movie) // Most recent first
|
||||
val trimmed = current.take(MAX_HISTORY)
|
||||
prefs[WATCH_HISTORY_KEY] = movieListAdapter.toJson(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Theme ---
|
||||
|
||||
val theme: Flow<String> = context.dataStore.data.map { prefs ->
|
||||
prefs[THEME_KEY] ?: "default"
|
||||
}
|
||||
|
||||
suspend fun setTheme(theme: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[THEME_KEY] = theme
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server URL ---
|
||||
|
||||
val serverUrl: Flow<String> = context.dataStore.data.map { prefs ->
|
||||
prefs[SERVER_URL_KEY] ?: "https://nf.khoavo.myds.me"
|
||||
}
|
||||
|
||||
suspend fun setServerUrl(url: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[SERVER_URL_KEY] = url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,76 +1,76 @@
|
|||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||
import androidx.tv.foundation.lazy.grid.items
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.data.model.Episode
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun EpisodeSelector(
|
||||
episodes: List<Episode>,
|
||||
currentEpisode: Int,
|
||||
onEpisodeSelect: (Episode) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = "Episodes",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
android.util.Log.e("EpisodeSelector", "Rendering grid with ${episodes.size} episodes")
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(minSize = 120.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(episodes) { episode ->
|
||||
val isActive = episode.number == currentEpisode
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
onClick = { onEpisodeSelect(episode) },
|
||||
modifier = Modifier
|
||||
.onFocusChanged { isFocused = it.isFocused },
|
||||
shape = ClickableSurfaceDefaults.shape(
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (isActive) colors.primary.copy(alpha = 0.2f) else colors.surfaceVariant,
|
||||
focusedContainerColor = colors.primary.copy(alpha = 0.3f)
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp, horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (episode.title.isNotBlank()) episode.title else "Ep ${episode.number}",
|
||||
style = StreamFlowTheme.typography.labelLarge.copy(
|
||||
color = if (isActive) colors.primary else Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||
import androidx.tv.foundation.lazy.grid.items
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.data.model.Episode
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun EpisodeSelector(
|
||||
episodes: List<Episode>,
|
||||
currentEpisode: Int,
|
||||
onEpisodeSelect: (Episode) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = "Episodes",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
android.util.Log.e("EpisodeSelector", "Rendering grid with ${episodes.size} episodes")
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(minSize = 120.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(episodes) { episode ->
|
||||
val isActive = episode.number == currentEpisode
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
onClick = { onEpisodeSelect(episode) },
|
||||
modifier = Modifier
|
||||
.onFocusChanged { isFocused = it.isFocused },
|
||||
shape = ClickableSurfaceDefaults.shape(
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (isActive) colors.primary.copy(alpha = 0.2f) else colors.surfaceVariant,
|
||||
focusedContainerColor = colors.primary.copy(alpha = 0.3f)
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp, horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (episode.title.isNotBlank()) episode.title else "Ep ${episode.number}",
|
||||
style = StreamFlowTheme.typography.labelLarge.copy(
|
||||
color = if (isActive) colors.primary else Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,159 +1,159 @@
|
|||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import coil.compose.AsyncImage
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun HeroBanner(
|
||||
movies: List<Movie>,
|
||||
onPlayClick: (Movie) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (movies.isEmpty()) return
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
var currentIndex by remember { mutableIntStateOf(0) }
|
||||
val currentMovie = movies[currentIndex]
|
||||
|
||||
LaunchedEffect(currentIndex) {
|
||||
delay(6000)
|
||||
currentIndex = (currentIndex + 1) % movies.size
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(480.dp)
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = currentMovie,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
label = "hero-crossfade"
|
||||
) { movie ->
|
||||
AsyncImage(
|
||||
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
|
||||
contentDescription = movie.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
colors.background.copy(alpha = 0.9f),
|
||||
colors.background.copy(alpha = 0.5f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.4f)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, colors.background)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = 48.dp, end = 200.dp)
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
currentMovie.quality?.let { quality ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(colors.primary, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = quality,
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = currentMovie.title,
|
||||
style = StreamFlowTheme.typography.displayLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
currentMovie.year?.let {
|
||||
Text("$it", style = StreamFlowTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Surface(
|
||||
onClick = { onPlayClick(currentMovie) },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.primary,
|
||||
focusedContainerColor = colors.accent
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Text(
|
||||
text = "▶ Play Now",
|
||||
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
movies.forEachIndexed { index, _ ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(if (index == currentIndex) 24.dp else 8.dp, 8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (index == currentIndex) colors.primary
|
||||
else Color.White.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import coil.compose.AsyncImage
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun HeroBanner(
|
||||
movies: List<Movie>,
|
||||
onPlayClick: (Movie) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (movies.isEmpty()) return
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
var currentIndex by remember { mutableIntStateOf(0) }
|
||||
val currentMovie = movies[currentIndex]
|
||||
|
||||
LaunchedEffect(currentIndex) {
|
||||
delay(6000)
|
||||
currentIndex = (currentIndex + 1) % movies.size
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(480.dp)
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = currentMovie,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
label = "hero-crossfade"
|
||||
) { movie ->
|
||||
AsyncImage(
|
||||
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
|
||||
contentDescription = movie.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
colors.background.copy(alpha = 0.9f),
|
||||
colors.background.copy(alpha = 0.5f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.4f)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, colors.background)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = 48.dp, end = 200.dp)
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
currentMovie.quality?.let { quality ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(colors.primary, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = quality,
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = currentMovie.title,
|
||||
style = StreamFlowTheme.typography.displayLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
currentMovie.year?.let {
|
||||
Text("$it", style = StreamFlowTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Surface(
|
||||
onClick = { onPlayClick(currentMovie) },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.primary,
|
||||
focusedContainerColor = colors.accent
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Text(
|
||||
text = "▶ Play Now",
|
||||
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
movies.forEachIndexed { index, _ ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(if (index == currentIndex) 24.dp else 8.dp, 8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (index == currentIndex) colors.primary
|
||||
else Color.White.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,116 +1,116 @@
|
|||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import coil.compose.AsyncImage
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun MovieCard(
|
||||
movie: Movie,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.width(200.dp)
|
||||
.height(300.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.surfaceVariant,
|
||||
focusedContainerColor = colors.surfaceVariant
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AsyncImage(
|
||||
model = ApiClient.imageProxyUrl(movie.thumbnail, 300),
|
||||
contentDescription = movie.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
|
||||
movie.quality?.let { quality ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
.background(colors.primary, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = quality,
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
movie.provider?.let { provider ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.align(Alignment.TopStart)
|
||||
.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = provider,
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
fontSize = androidx.compose.ui.unit.TextUnit.Unspecified // Default or small
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f))
|
||||
)
|
||||
)
|
||||
.padding(horizontal = 10.dp, vertical = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = movie.title,
|
||||
style = StreamFlowTheme.typography.labelLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
movie.year?.let { year ->
|
||||
Text(
|
||||
text = year.toString(),
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color.White.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import coil.compose.AsyncImage
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun MovieCard(
|
||||
movie: Movie,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.width(200.dp)
|
||||
.height(300.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.surfaceVariant,
|
||||
focusedContainerColor = colors.surfaceVariant
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AsyncImage(
|
||||
model = ApiClient.imageProxyUrl(movie.thumbnail, 300),
|
||||
contentDescription = movie.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
|
||||
movie.quality?.let { quality ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
.background(colors.primary, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = quality,
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
movie.provider?.let { provider ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.align(Alignment.TopStart)
|
||||
.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = provider,
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
fontSize = androidx.compose.ui.unit.TextUnit.Unspecified // Default or small
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f))
|
||||
)
|
||||
)
|
||||
.padding(horizontal = 10.dp, vertical = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = movie.title,
|
||||
style = StreamFlowTheme.typography.labelLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
movie.year?.let { year ->
|
||||
Text(
|
||||
text = year.toString(),
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color.White.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,43 @@
|
|||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.foundation.lazy.list.TvLazyRow
|
||||
import androidx.tv.foundation.lazy.list.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun MovieRow(
|
||||
title: String,
|
||||
movies: List<Movie>,
|
||||
onMovieClick: (Movie) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier.padding(vertical = 12.dp)) {
|
||||
// Section title
|
||||
Text(
|
||||
text = title,
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(start = 48.dp, bottom = 12.dp)
|
||||
)
|
||||
|
||||
// Horizontal scrolling row of cards
|
||||
TvLazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 48.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(movies) { movie ->
|
||||
MovieCard(
|
||||
movie = movie,
|
||||
onClick = { onMovieClick(movie) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.foundation.lazy.list.TvLazyRow
|
||||
import androidx.tv.foundation.lazy.list.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun MovieRow(
|
||||
title: String,
|
||||
movies: List<Movie>,
|
||||
onMovieClick: (Movie) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier.padding(vertical = 12.dp)) {
|
||||
// Section title
|
||||
Text(
|
||||
text = title,
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(start = 48.dp, bottom = 12.dp)
|
||||
)
|
||||
|
||||
// Horizontal scrolling row of cards
|
||||
TvLazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 48.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(movies) { movie ->
|
||||
MovieCard(
|
||||
movie = movie,
|
||||
onClick = { onMovieClick(movie) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,127 +1,127 @@
|
|||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
|
||||
data class NavItem(
|
||||
val id: String,
|
||||
val route: String,
|
||||
val label: String,
|
||||
val icon: ImageVector
|
||||
)
|
||||
|
||||
val NAV_ITEMS = listOf(
|
||||
NavItem("home", "home", "Home", Icons.Default.Home),
|
||||
NavItem("categories", "home/phim-le", "Categories", Icons.Default.Category),
|
||||
NavItem("search", "search", "Search", Icons.Default.Search),
|
||||
NavItem("mylist", "mylist", "My List", Icons.Default.Favorite),
|
||||
NavItem("settings", "settings", "Settings", Icons.Default.Settings)
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun SideNavRail(
|
||||
selectedId: String,
|
||||
onNavigate: (NavItem) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
focusRequester.requestFocus()
|
||||
} catch (e: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(56.dp)
|
||||
.background(colors.background.copy(alpha = 0.95f))
|
||||
.padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(colors.primary),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("S", style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
NAV_ITEMS.forEach { item ->
|
||||
NavRailItem(
|
||||
item = item,
|
||||
isSelected = selectedId == item.id,
|
||||
onClick = { onNavigate(item) },
|
||||
accentColor = colors.primary,
|
||||
modifier = if (item.id == "home") Modifier.focusRequester(focusRequester) else Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun NavRailItem(
|
||||
item: NavItem,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
accentColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.size(48.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (isSelected) accentColor.copy(alpha = 0.15f) else Color.Transparent,
|
||||
focusedContainerColor = accentColor.copy(alpha = 0.2f)
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = item.label,
|
||||
tint = if (isSelected) accentColor else Color.White.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
|
||||
data class NavItem(
|
||||
val id: String,
|
||||
val route: String,
|
||||
val label: String,
|
||||
val icon: ImageVector
|
||||
)
|
||||
|
||||
val NAV_ITEMS = listOf(
|
||||
NavItem("home", "home", "Home", Icons.Default.Home),
|
||||
NavItem("categories", "home/phim-le", "Categories", Icons.Default.Category),
|
||||
NavItem("search", "search", "Search", Icons.Default.Search),
|
||||
NavItem("mylist", "mylist", "My List", Icons.Default.Favorite),
|
||||
NavItem("settings", "settings", "Settings", Icons.Default.Settings)
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun SideNavRail(
|
||||
selectedId: String,
|
||||
onNavigate: (NavItem) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
focusRequester.requestFocus()
|
||||
} catch (e: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(56.dp)
|
||||
.background(colors.background.copy(alpha = 0.95f))
|
||||
.padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(colors.primary),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("S", style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
NAV_ITEMS.forEach { item ->
|
||||
NavRailItem(
|
||||
item = item,
|
||||
isSelected = selectedId == item.id,
|
||||
onClick = { onNavigate(item) },
|
||||
accentColor = colors.primary,
|
||||
modifier = if (item.id == "home") Modifier.focusRequester(focusRequester) else Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun NavRailItem(
|
||||
item: NavItem,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
accentColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.size(48.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (isSelected) accentColor.copy(alpha = 0.15f) else Color.Transparent,
|
||||
focusedContainerColor = accentColor.copy(alpha = 0.2f)
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = item.label,
|
||||
tint = if (isSelected) accentColor else Color.White.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,98 +1,98 @@
|
|||
package com.streamflow.tv.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.streamflow.tv.ui.screens.*
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
currentTheme: String,
|
||||
onThemeChange: (String) -> Unit
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(navController = navController, startDestination = "home") {
|
||||
// Home (all categories)
|
||||
composable("home") {
|
||||
HomeScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
// Home filtered by category
|
||||
composable(
|
||||
"home/{category}",
|
||||
arguments = listOf(navArgument("category") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val category = backStackEntry.arguments?.getString("category")
|
||||
HomeScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") },
|
||||
category = category
|
||||
)
|
||||
}
|
||||
|
||||
// Movie Detail
|
||||
composable(
|
||||
"detail/{slug}",
|
||||
arguments = listOf(navArgument("slug") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
|
||||
DetailScreen(
|
||||
slug = slug,
|
||||
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
// Video Player
|
||||
composable(
|
||||
"player/{slug}/{episode}",
|
||||
arguments = listOf(
|
||||
navArgument("slug") { type = NavType.StringType },
|
||||
navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
|
||||
val episode = backStackEntry.arguments?.getInt("episode") ?: 1
|
||||
PlayerScreen(slug = slug, episode = episode)
|
||||
}
|
||||
|
||||
// Search
|
||||
composable("search") {
|
||||
SearchScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
// My List
|
||||
composable("mylist") {
|
||||
MyListScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
// Settings
|
||||
composable("settings") {
|
||||
SettingsScreen(
|
||||
currentTheme = currentTheme,
|
||||
onThemeChange = onThemeChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Expose navController for SideNavRail
|
||||
LaunchedEffect(navController) {
|
||||
// Store nav controller reference for side nav
|
||||
}
|
||||
|
||||
// Provide nav controller via local
|
||||
CompositionLocalProvider(LocalNavController provides navController) {}
|
||||
}
|
||||
|
||||
val LocalNavController = staticCompositionLocalOf<androidx.navigation.NavHostController> {
|
||||
error("NavController not provided")
|
||||
}
|
||||
package com.streamflow.tv.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.streamflow.tv.ui.screens.*
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
currentTheme: String,
|
||||
onThemeChange: (String) -> Unit
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(navController = navController, startDestination = "home") {
|
||||
// Home (all categories)
|
||||
composable("home") {
|
||||
HomeScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
// Home filtered by category
|
||||
composable(
|
||||
"home/{category}",
|
||||
arguments = listOf(navArgument("category") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val category = backStackEntry.arguments?.getString("category")
|
||||
HomeScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") },
|
||||
category = category
|
||||
)
|
||||
}
|
||||
|
||||
// Movie Detail
|
||||
composable(
|
||||
"detail/{slug}",
|
||||
arguments = listOf(navArgument("slug") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
|
||||
DetailScreen(
|
||||
slug = slug,
|
||||
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
// Video Player
|
||||
composable(
|
||||
"player/{slug}/{episode}",
|
||||
arguments = listOf(
|
||||
navArgument("slug") { type = NavType.StringType },
|
||||
navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
|
||||
val episode = backStackEntry.arguments?.getInt("episode") ?: 1
|
||||
PlayerScreen(slug = slug, episode = episode)
|
||||
}
|
||||
|
||||
// Search
|
||||
composable("search") {
|
||||
SearchScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
// My List
|
||||
composable("mylist") {
|
||||
MyListScreen(
|
||||
onMovieClick = { slug -> navController.navigate("detail/$slug") }
|
||||
)
|
||||
}
|
||||
|
||||
// Settings
|
||||
composable("settings") {
|
||||
SettingsScreen(
|
||||
currentTheme = currentTheme,
|
||||
onThemeChange = onThemeChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Expose navController for SideNavRail
|
||||
LaunchedEffect(navController) {
|
||||
// Store nav controller reference for side nav
|
||||
}
|
||||
|
||||
// Provide nav controller via local
|
||||
CompositionLocalProvider(LocalNavController provides navController) {}
|
||||
}
|
||||
|
||||
val LocalNavController = staticCompositionLocalOf<androidx.navigation.NavHostController> {
|
||||
error("NavController not provided")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,200 +1,200 @@
|
|||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import android.util.Log
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.Episode
|
||||
import com.streamflow.tv.ui.components.EpisodeSelector
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.DetailViewModel
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun DetailScreen(
|
||||
slug: String,
|
||||
onPlayClick: (String, Int) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
viewModel: DetailViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
LaunchedEffect(slug) {
|
||||
viewModel.loadMovie(slug)
|
||||
}
|
||||
|
||||
Log.d("DetailScreen", "Composing DetailScreen(slug=$slug, isLoading=${uiState.isLoading})")
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularLoadingIndicator()
|
||||
} else if (uiState.error != null) {
|
||||
ErrorState(message = uiState.error ?: "Unknown error", onRetry = { viewModel.loadMovie(slug) })
|
||||
} else {
|
||||
val movie = uiState.movie ?: return@Box
|
||||
Log.d("DetailScreen", "Rendering movie details: ${movie.title}")
|
||||
|
||||
// Background Image
|
||||
AsyncImage(
|
||||
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
// Gradient Overlays
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
colors.background.copy(alpha = 0.95f),
|
||||
colors.background.copy(alpha = 0.7f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.3f)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, colors.background)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Content
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(uiState.movie) {
|
||||
if (uiState.movie != null) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 48.dp, vertical = 32.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = movie.title,
|
||||
style = StreamFlowTheme.typography.displayLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = movie.description,
|
||||
style = StreamFlowTheme.typography.bodyMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 600.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
Surface(
|
||||
onClick = { onPlayClick(movie.slug, 1) },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.primary,
|
||||
focusedContainerColor = colors.accent
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
|
||||
modifier = Modifier.focusRequester(focusRequester)
|
||||
) {
|
||||
Text(
|
||||
"▶ Play",
|
||||
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (!movie.episodes.isNullOrEmpty()) {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
EpisodeSelector(
|
||||
episodes = movie.episodes,
|
||||
currentEpisode = 1,
|
||||
onEpisodeSelect = { episode -> onPlayClick(movie.slug, episode.number) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CircularLoadingIndicator() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "Loading...",
|
||||
style = StreamFlowTheme.typography.headlineMedium.copy(color = StreamFlowTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun ErrorState(message: String, onRetry: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
Text(
|
||||
text = message,
|
||||
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red),
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
Surface(
|
||||
onClick = onRetry,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
"Retry",
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import android.util.Log
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.model.Episode
|
||||
import com.streamflow.tv.ui.components.EpisodeSelector
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.DetailViewModel
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun DetailScreen(
|
||||
slug: String,
|
||||
onPlayClick: (String, Int) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
viewModel: DetailViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
LaunchedEffect(slug) {
|
||||
viewModel.loadMovie(slug)
|
||||
}
|
||||
|
||||
Log.d("DetailScreen", "Composing DetailScreen(slug=$slug, isLoading=${uiState.isLoading})")
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularLoadingIndicator()
|
||||
} else if (uiState.error != null) {
|
||||
ErrorState(message = uiState.error ?: "Unknown error", onRetry = { viewModel.loadMovie(slug) })
|
||||
} else {
|
||||
val movie = uiState.movie ?: return@Box
|
||||
Log.d("DetailScreen", "Rendering movie details: ${movie.title}")
|
||||
|
||||
// Background Image
|
||||
AsyncImage(
|
||||
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
// Gradient Overlays
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
colors.background.copy(alpha = 0.95f),
|
||||
colors.background.copy(alpha = 0.7f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.3f)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, colors.background)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Content
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(uiState.movie) {
|
||||
if (uiState.movie != null) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 48.dp, vertical = 32.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = movie.title,
|
||||
style = StreamFlowTheme.typography.displayLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = movie.description,
|
||||
style = StreamFlowTheme.typography.bodyMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 600.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
Surface(
|
||||
onClick = { onPlayClick(movie.slug, 1) },
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.primary,
|
||||
focusedContainerColor = colors.accent
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
|
||||
modifier = Modifier.focusRequester(focusRequester)
|
||||
) {
|
||||
Text(
|
||||
"▶ Play",
|
||||
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (!movie.episodes.isNullOrEmpty()) {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
EpisodeSelector(
|
||||
episodes = movie.episodes,
|
||||
currentEpisode = 1,
|
||||
onEpisodeSelect = { episode -> onPlayClick(movie.slug, episode.number) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CircularLoadingIndicator() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "Loading...",
|
||||
style = StreamFlowTheme.typography.headlineMedium.copy(color = StreamFlowTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun ErrorState(message: String, onRetry: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
Text(
|
||||
text = message,
|
||||
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red),
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
Surface(
|
||||
onClick = onRetry,
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
"Retry",
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,112 +1,112 @@
|
|||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.tv.foundation.lazy.list.TvLazyColumn
|
||||
import androidx.tv.foundation.lazy.list.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.ui.components.HeroBanner
|
||||
import com.streamflow.tv.ui.components.MovieRow
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.HomeViewModel
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
onMovieClick: (String) -> Unit,
|
||||
category: String? = null,
|
||||
userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null,
|
||||
viewModel: HomeViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
LaunchedEffect(category) {
|
||||
viewModel.loadHome(category, userDataRepository)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Loading...",
|
||||
style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary)
|
||||
)
|
||||
}
|
||||
} else if (uiState.error != null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = uiState.error ?: "Unknown error",
|
||||
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TvLazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp)
|
||||
) {
|
||||
// Hero Banner
|
||||
if (uiState.heroMovies.isNotEmpty()) {
|
||||
item {
|
||||
HeroBanner(
|
||||
movies = uiState.heroMovies,
|
||||
onPlayClick = { movie -> onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Continue Watching (Watch History)
|
||||
if (uiState.watchedMovies.isNotEmpty()) {
|
||||
item {
|
||||
MovieRow(
|
||||
title = "Continue Watching",
|
||||
movies = uiState.watchedMovies,
|
||||
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Recommended for You
|
||||
if (uiState.recommendedMovies.isNotEmpty()) {
|
||||
item {
|
||||
MovieRow(
|
||||
title = "Recommended for You",
|
||||
movies = uiState.recommendedMovies,
|
||||
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Category rows
|
||||
uiState.categoryMovies.forEach { (title, movies) ->
|
||||
if (movies.isNotEmpty()) {
|
||||
item {
|
||||
MovieRow(
|
||||
title = title,
|
||||
movies = movies,
|
||||
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.tv.foundation.lazy.list.TvLazyColumn
|
||||
import androidx.tv.foundation.lazy.list.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.ui.components.HeroBanner
|
||||
import com.streamflow.tv.ui.components.MovieRow
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.HomeViewModel
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
onMovieClick: (String) -> Unit,
|
||||
category: String? = null,
|
||||
userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null,
|
||||
viewModel: HomeViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
LaunchedEffect(category) {
|
||||
viewModel.loadHome(category, userDataRepository)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Loading...",
|
||||
style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary)
|
||||
)
|
||||
}
|
||||
} else if (uiState.error != null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = uiState.error ?: "Unknown error",
|
||||
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TvLazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp)
|
||||
) {
|
||||
// Hero Banner
|
||||
if (uiState.heroMovies.isNotEmpty()) {
|
||||
item {
|
||||
HeroBanner(
|
||||
movies = uiState.heroMovies,
|
||||
onPlayClick = { movie -> onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Continue Watching (Watch History)
|
||||
if (uiState.watchedMovies.isNotEmpty()) {
|
||||
item {
|
||||
MovieRow(
|
||||
title = "Continue Watching",
|
||||
movies = uiState.watchedMovies,
|
||||
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Recommended for You
|
||||
if (uiState.recommendedMovies.isNotEmpty()) {
|
||||
item {
|
||||
MovieRow(
|
||||
title = "Recommended for You",
|
||||
movies = uiState.recommendedMovies,
|
||||
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Category rows
|
||||
uiState.categoryMovies.forEach { (title, movies) ->
|
||||
if (movies.isNotEmpty()) {
|
||||
item {
|
||||
MovieRow(
|
||||
title = title,
|
||||
movies = movies,
|
||||
onMovieClick = { movie -> onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,104 +1,104 @@
|
|||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||
import androidx.tv.foundation.lazy.grid.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.ui.components.MovieCard
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.MyListViewModel
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun MyListScreen(
|
||||
onMovieClick: (String) -> Unit,
|
||||
viewModel: MyListViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
.padding(horizontal = 48.dp, vertical = 32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "My List",
|
||||
style = StreamFlowTheme.typography.displayMedium,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
if (uiState.watchHistory.isEmpty() && uiState.savedMovies.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("❤️", style = StreamFlowTheme.typography.displayLarge)
|
||||
Text(
|
||||
"Your list is empty.",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
Text(
|
||||
"Start watching or add movies to your list.",
|
||||
style = StreamFlowTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Continue Watching
|
||||
if (uiState.watchHistory.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Continue Watching",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(180.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.heightIn(max = 320.dp)
|
||||
) {
|
||||
items(uiState.watchHistory, key = { "h_${it.slug}" }) { movie ->
|
||||
MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) })
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
// Saved
|
||||
if (uiState.savedMovies.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Saved Movies",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(180.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(uiState.savedMovies, key = { "s_${it.slug}" }) { movie ->
|
||||
MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||
import androidx.tv.foundation.lazy.grid.items
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.ui.components.MovieCard
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.MyListViewModel
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun MyListScreen(
|
||||
onMovieClick: (String) -> Unit,
|
||||
viewModel: MyListViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val colors = StreamFlowTheme.colors
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
.padding(horizontal = 48.dp, vertical = 32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "My List",
|
||||
style = StreamFlowTheme.typography.displayMedium,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
if (uiState.watchHistory.isEmpty() && uiState.savedMovies.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("❤️", style = StreamFlowTheme.typography.displayLarge)
|
||||
Text(
|
||||
"Your list is empty.",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
Text(
|
||||
"Start watching or add movies to your list.",
|
||||
style = StreamFlowTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Continue Watching
|
||||
if (uiState.watchHistory.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Continue Watching",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(180.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.heightIn(max = 320.dp)
|
||||
) {
|
||||
items(uiState.watchHistory, key = { "h_${it.slug}" }) { movie ->
|
||||
MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) })
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
// Saved
|
||||
if (uiState.savedMovies.isNotEmpty()) {
|
||||
Text(
|
||||
text = "Saved Movies",
|
||||
style = StreamFlowTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(180.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(uiState.savedMovies, key = { "s_${it.slug}" }) { movie ->
|
||||
MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,249 +1,249 @@
|
|||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.PlayerViewModel
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
slug: String,
|
||||
episode: Int = 1,
|
||||
userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null,
|
||||
viewModel: PlayerViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val colors = StreamFlowTheme.colors
|
||||
var playerView by remember { mutableStateOf<PlayerView?>(null) }
|
||||
|
||||
LaunchedEffect(slug, episode) {
|
||||
viewModel.loadPlayer(slug, episode)
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.movie) {
|
||||
if (uiState.movie != null && userDataRepository != null) {
|
||||
viewModel.saveToHistory(userDataRepository)
|
||||
}
|
||||
}
|
||||
|
||||
// ExoPlayer instance
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap ExoPlayer to intercept next/previous UI clicks
|
||||
val forwardingPlayer = remember(exoPlayer, uiState.movie, uiState.currentEpisode) {
|
||||
object : androidx.media3.common.ForwardingPlayer(exoPlayer) {
|
||||
override fun getAvailableCommands(): androidx.media3.common.Player.Commands {
|
||||
return super.getAvailableCommands().buildUpon()
|
||||
.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_NEXT_MEDIA_ITEM)
|
||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun hasNextMediaItem(): Boolean {
|
||||
val eps = uiState.movie?.episodes ?: return false
|
||||
if (eps.isEmpty()) return false
|
||||
val maxEp = eps.maxOf { it.number }
|
||||
return uiState.currentEpisode < maxEp
|
||||
}
|
||||
override fun hasPreviousMediaItem(): Boolean {
|
||||
val eps = uiState.movie?.episodes ?: return false
|
||||
if (eps.isEmpty()) return false
|
||||
val minEp = eps.minOf { it.number }
|
||||
return uiState.currentEpisode > minEp
|
||||
}
|
||||
override fun seekToNextMediaItem() {
|
||||
if (hasNextMediaItem()) {
|
||||
viewModel.changeEpisode(uiState.currentEpisode + 1)
|
||||
}
|
||||
}
|
||||
override fun seekToNext() {
|
||||
seekToNextMediaItem()
|
||||
}
|
||||
override fun seekToPreviousMediaItem() {
|
||||
if (hasPreviousMediaItem()) {
|
||||
viewModel.changeEpisode(uiState.currentEpisode - 1)
|
||||
}
|
||||
}
|
||||
override fun seekToPrevious() {
|
||||
seekToPreviousMediaItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update player when source changes
|
||||
LaunchedEffect(uiState.source) {
|
||||
uiState.source?.let { source ->
|
||||
val dataSourceFactory = DefaultDataSource.Factory(context)
|
||||
val mediaItem = MediaItem.fromUri(source.streamUrl)
|
||||
|
||||
android.util.Log.e("StreamFlowPlayer", "Setting media source: ${source.streamUrl}")
|
||||
|
||||
exoPlayer.addListener(object : androidx.media3.common.Player.Listener {
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
android.util.Log.e("StreamFlowPlayer", "Player Error: ${error.message}", error)
|
||||
}
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
android.util.Log.e("StreamFlowPlayer", "Playback State: $playbackState")
|
||||
}
|
||||
})
|
||||
|
||||
if (source.streamUrl.contains(".m3u8")) {
|
||||
val hlsSource = HlsMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(mediaItem)
|
||||
exoPlayer.setMediaSource(hlsSource)
|
||||
} else {
|
||||
exoPlayer.setMediaItem(mediaItem)
|
||||
}
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onPreviewKeyEvent { keyEvent ->
|
||||
if (keyEvent.type == KeyEventType.KeyDown) {
|
||||
when (keyEvent.nativeKeyEvent.keyCode) {
|
||||
android.view.KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
android.view.KeyEvent.KEYCODE_ENTER -> {
|
||||
// Toggle controls visibility
|
||||
if (playerView?.isControllerFullyVisible == true) {
|
||||
playerView?.hideController()
|
||||
} else {
|
||||
playerView?.showController()
|
||||
}
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||
// Seek backward 10s
|
||||
playerView?.showController()
|
||||
exoPlayer.seekTo(maxOf(0, exoPlayer.currentPosition - 10000))
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||
// Seek forward 10s
|
||||
playerView?.showController()
|
||||
exoPlayer.seekTo(minOf(exoPlayer.duration, exoPlayer.currentPosition + 10000))
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_DPAD_UP,
|
||||
android.view.KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||
playerView?.showController()
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||
if (forwardingPlayer.hasNextMediaItem()) {
|
||||
forwardingPlayer.seekToNextMediaItem()
|
||||
}
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||
if (forwardingPlayer.hasPreviousMediaItem()) {
|
||||
forwardingPlayer.seekToPreviousMediaItem()
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
} else false
|
||||
}
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
if (uiState.isLoading || uiState.source == null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"Loading stream...",
|
||||
style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary)
|
||||
)
|
||||
uiState.movie?.let { movie ->
|
||||
Text(
|
||||
movie.title,
|
||||
style = StreamFlowTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ExoPlayer View
|
||||
android.util.Log.e("StreamFlowPlayer", "Drawing AndroidView for Player")
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
android.util.Log.e("StreamFlowPlayer", "Creating PlayerView factory")
|
||||
PlayerView(ctx).apply {
|
||||
player = forwardingPlayer
|
||||
useController = true
|
||||
setShowNextButton(true)
|
||||
setShowPreviousButton(true)
|
||||
controllerAutoShow = true
|
||||
keepScreenOn = true // Prevent screen sleep during playback
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
playerView = this
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
// Error overlay
|
||||
uiState.error?.let { error ->
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
error,
|
||||
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.Text
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.PlayerViewModel
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@kotlin.OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
slug: String,
|
||||
episode: Int = 1,
|
||||
userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null,
|
||||
viewModel: PlayerViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val colors = StreamFlowTheme.colors
|
||||
var playerView by remember { mutableStateOf<PlayerView?>(null) }
|
||||
|
||||
LaunchedEffect(slug, episode) {
|
||||
viewModel.loadPlayer(slug, episode)
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.movie) {
|
||||
if (uiState.movie != null && userDataRepository != null) {
|
||||
viewModel.saveToHistory(userDataRepository)
|
||||
}
|
||||
}
|
||||
|
||||
// ExoPlayer instance
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap ExoPlayer to intercept next/previous UI clicks
|
||||
val forwardingPlayer = remember(exoPlayer, uiState.movie, uiState.currentEpisode) {
|
||||
object : androidx.media3.common.ForwardingPlayer(exoPlayer) {
|
||||
override fun getAvailableCommands(): androidx.media3.common.Player.Commands {
|
||||
return super.getAvailableCommands().buildUpon()
|
||||
.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_NEXT_MEDIA_ITEM)
|
||||
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun hasNextMediaItem(): Boolean {
|
||||
val eps = uiState.movie?.episodes ?: return false
|
||||
if (eps.isEmpty()) return false
|
||||
val maxEp = eps.maxOf { it.number }
|
||||
return uiState.currentEpisode < maxEp
|
||||
}
|
||||
override fun hasPreviousMediaItem(): Boolean {
|
||||
val eps = uiState.movie?.episodes ?: return false
|
||||
if (eps.isEmpty()) return false
|
||||
val minEp = eps.minOf { it.number }
|
||||
return uiState.currentEpisode > minEp
|
||||
}
|
||||
override fun seekToNextMediaItem() {
|
||||
if (hasNextMediaItem()) {
|
||||
viewModel.changeEpisode(uiState.currentEpisode + 1)
|
||||
}
|
||||
}
|
||||
override fun seekToNext() {
|
||||
seekToNextMediaItem()
|
||||
}
|
||||
override fun seekToPreviousMediaItem() {
|
||||
if (hasPreviousMediaItem()) {
|
||||
viewModel.changeEpisode(uiState.currentEpisode - 1)
|
||||
}
|
||||
}
|
||||
override fun seekToPrevious() {
|
||||
seekToPreviousMediaItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update player when source changes
|
||||
LaunchedEffect(uiState.source) {
|
||||
uiState.source?.let { source ->
|
||||
val dataSourceFactory = DefaultDataSource.Factory(context)
|
||||
val mediaItem = MediaItem.fromUri(source.streamUrl)
|
||||
|
||||
android.util.Log.e("StreamFlowPlayer", "Setting media source: ${source.streamUrl}")
|
||||
|
||||
exoPlayer.addListener(object : androidx.media3.common.Player.Listener {
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
android.util.Log.e("StreamFlowPlayer", "Player Error: ${error.message}", error)
|
||||
}
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
android.util.Log.e("StreamFlowPlayer", "Playback State: $playbackState")
|
||||
}
|
||||
})
|
||||
|
||||
if (source.streamUrl.contains(".m3u8")) {
|
||||
val hlsSource = HlsMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(mediaItem)
|
||||
exoPlayer.setMediaSource(hlsSource)
|
||||
} else {
|
||||
exoPlayer.setMediaItem(mediaItem)
|
||||
}
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onPreviewKeyEvent { keyEvent ->
|
||||
if (keyEvent.type == KeyEventType.KeyDown) {
|
||||
when (keyEvent.nativeKeyEvent.keyCode) {
|
||||
android.view.KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
android.view.KeyEvent.KEYCODE_ENTER -> {
|
||||
// Toggle controls visibility
|
||||
if (playerView?.isControllerFullyVisible == true) {
|
||||
playerView?.hideController()
|
||||
} else {
|
||||
playerView?.showController()
|
||||
}
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||
// Seek backward 10s
|
||||
playerView?.showController()
|
||||
exoPlayer.seekTo(maxOf(0, exoPlayer.currentPosition - 10000))
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||
// Seek forward 10s
|
||||
playerView?.showController()
|
||||
exoPlayer.seekTo(minOf(exoPlayer.duration, exoPlayer.currentPosition + 10000))
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_DPAD_UP,
|
||||
android.view.KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||
playerView?.showController()
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||
if (forwardingPlayer.hasNextMediaItem()) {
|
||||
forwardingPlayer.seekToNextMediaItem()
|
||||
}
|
||||
true
|
||||
}
|
||||
android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||
if (forwardingPlayer.hasPreviousMediaItem()) {
|
||||
forwardingPlayer.seekToPreviousMediaItem()
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
} else false
|
||||
}
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
if (uiState.isLoading || uiState.source == null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"Loading stream...",
|
||||
style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary)
|
||||
)
|
||||
uiState.movie?.let { movie ->
|
||||
Text(
|
||||
movie.title,
|
||||
style = StreamFlowTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ExoPlayer View
|
||||
android.util.Log.e("StreamFlowPlayer", "Drawing AndroidView for Player")
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
android.util.Log.e("StreamFlowPlayer", "Creating PlayerView factory")
|
||||
PlayerView(ctx).apply {
|
||||
player = forwardingPlayer
|
||||
useController = true
|
||||
setShowNextButton(true)
|
||||
setShowPreviousButton(true)
|
||||
controllerAutoShow = true
|
||||
keepScreenOn = true // Prevent screen sleep during playback
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
playerView = this
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
// Error overlay
|
||||
uiState.error?.let { error ->
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
error,
|
||||
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,124 +1,124 @@
|
|||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||
import androidx.tv.foundation.lazy.grid.items
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.ui.components.MovieCard
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.SearchViewModel
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
onMovieClick: (String) -> Unit,
|
||||
viewModel: SearchViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val colors = StreamFlowTheme.colors
|
||||
var textValue by remember { mutableStateOf(TextFieldValue("")) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
.padding(horizontal = 48.dp, vertical = 32.dp)
|
||||
) {
|
||||
// Search bar
|
||||
Text(
|
||||
text = "Search",
|
||||
style = StreamFlowTheme.typography.displayMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(colors.surfaceVariant, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text("🔍 ", style = StreamFlowTheme.typography.titleMedium)
|
||||
BasicTextField(
|
||||
value = textValue,
|
||||
onValueChange = {
|
||||
textValue = it
|
||||
if (it.text.length >= 2) {
|
||||
viewModel.search(it.text)
|
||||
}
|
||||
},
|
||||
textStyle = StreamFlowTheme.typography.titleMedium,
|
||||
cursorBrush = SolidColor(colors.primary),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
decorationBox = { innerTextField ->
|
||||
Box {
|
||||
if (textValue.text.isEmpty()) {
|
||||
Text(
|
||||
"Type to search...",
|
||||
style = StreamFlowTheme.typography.titleMedium.copy(
|
||||
color = Color.White.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// Results
|
||||
when {
|
||||
uiState.isLoading -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Searching...", style = StreamFlowTheme.typography.bodyLarge.copy(color = colors.primary))
|
||||
}
|
||||
}
|
||||
uiState.results.isNotEmpty() -> {
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(180.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(uiState.results, key = { it.slug }) { movie ->
|
||||
MovieCard(
|
||||
movie = movie,
|
||||
onClick = { onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
uiState.hasSearched -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("No results found", style = StreamFlowTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("🎬", style = StreamFlowTheme.typography.displayLarge)
|
||||
Text(
|
||||
"Search for movies and shows",
|
||||
style = StreamFlowTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.tv.foundation.lazy.grid.TvGridCells
|
||||
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
|
||||
import androidx.tv.foundation.lazy.grid.items
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.ui.components.MovieCard
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import com.streamflow.tv.viewmodel.SearchViewModel
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
onMovieClick: (String) -> Unit,
|
||||
viewModel: SearchViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val colors = StreamFlowTheme.colors
|
||||
var textValue by remember { mutableStateOf(TextFieldValue("")) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
.padding(horizontal = 48.dp, vertical = 32.dp)
|
||||
) {
|
||||
// Search bar
|
||||
Text(
|
||||
text = "Search",
|
||||
style = StreamFlowTheme.typography.displayMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(colors.surfaceVariant, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text("🔍 ", style = StreamFlowTheme.typography.titleMedium)
|
||||
BasicTextField(
|
||||
value = textValue,
|
||||
onValueChange = {
|
||||
textValue = it
|
||||
if (it.text.length >= 2) {
|
||||
viewModel.search(it.text)
|
||||
}
|
||||
},
|
||||
textStyle = StreamFlowTheme.typography.titleMedium,
|
||||
cursorBrush = SolidColor(colors.primary),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
decorationBox = { innerTextField ->
|
||||
Box {
|
||||
if (textValue.text.isEmpty()) {
|
||||
Text(
|
||||
"Type to search...",
|
||||
style = StreamFlowTheme.typography.titleMedium.copy(
|
||||
color = Color.White.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// Results
|
||||
when {
|
||||
uiState.isLoading -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Searching...", style = StreamFlowTheme.typography.bodyLarge.copy(color = colors.primary))
|
||||
}
|
||||
}
|
||||
uiState.results.isNotEmpty() -> {
|
||||
TvLazyVerticalGrid(
|
||||
columns = TvGridCells.Adaptive(180.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(uiState.results, key = { it.slug }) { movie ->
|
||||
MovieCard(
|
||||
movie = movie,
|
||||
onClick = { onMovieClick(movie.slug) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
uiState.hasSearched -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("No results found", style = StreamFlowTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("🎬", style = StreamFlowTheme.typography.displayLarge)
|
||||
Text(
|
||||
"Search for movies and shows",
|
||||
style = StreamFlowTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,171 +1,171 @@
|
|||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.repository.UserDataRepository
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
currentTheme: String,
|
||||
onThemeChange: (String) -> Unit
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val userRepo = remember { UserDataRepository(context) }
|
||||
|
||||
var serverUrl by remember { mutableStateOf(TextFieldValue(ApiClient.baseUrl.removeSuffix("/"))) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val savedUrl = userRepo.serverUrl.first()
|
||||
serverUrl = TextFieldValue(savedUrl)
|
||||
}
|
||||
|
||||
val themes = listOf(
|
||||
Triple("default", "StreamFlow", Color(0xFF06B6D4)),
|
||||
Triple("netflix", "Netflix", Color(0xFFE50914)),
|
||||
Triple("apple", "Apple TV+", Color(0xFFFFFFFF))
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
.padding(horizontal = 48.dp, vertical = 32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = StreamFlowTheme.typography.displayMedium,
|
||||
modifier = Modifier.padding(bottom = 32.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "CHOOSE THEME",
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color.White.copy(alpha = 0.5f)
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
themes.forEach { (id, name, color) ->
|
||||
val isSelected = currentTheme == id
|
||||
|
||||
Surface(
|
||||
onClick = { onThemeChange(id) },
|
||||
modifier = Modifier.width(200.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (isSelected) Color.White.copy(alpha = 0.1f) else colors.surfaceVariant,
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.15f)
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(Color.Black, RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = name.first().toString(),
|
||||
style = StreamFlowTheme.typography.headlineLarge.copy(color = color)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = name,
|
||||
style = StreamFlowTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
if (isSelected) {
|
||||
Text(
|
||||
text = "✓ Active",
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color(0xFF22C55E)
|
||||
),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
Text(
|
||||
text = "SERVER URL",
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color.White.copy(alpha = 0.5f)
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
BasicTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
textStyle = StreamFlowTheme.typography.titleMedium,
|
||||
cursorBrush = SolidColor(colors.primary),
|
||||
modifier = Modifier
|
||||
.width(400.dp)
|
||||
.background(colors.surfaceVariant, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
|
||||
Surface(
|
||||
onClick = {
|
||||
val url = serverUrl.text.trim()
|
||||
ApiClient.baseUrl = url
|
||||
scope.launch { userRepo.setServerUrl(url) }
|
||||
},
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.primary,
|
||||
focusedContainerColor = colors.accent
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Text(
|
||||
"Save",
|
||||
style = StreamFlowTheme.typography.labelLarge.copy(color = Color.White),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter the IP address and port of your StreamFlow backend server.",
|
||||
style = StreamFlowTheme.typography.bodyMedium,
|
||||
modifier = Modifier.widthIn(max = 500.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.*
|
||||
import com.streamflow.tv.data.api.ApiClient
|
||||
import com.streamflow.tv.data.repository.UserDataRepository
|
||||
import com.streamflow.tv.ui.theme.StreamFlowTheme
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
currentTheme: String,
|
||||
onThemeChange: (String) -> Unit
|
||||
) {
|
||||
val colors = StreamFlowTheme.colors
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val userRepo = remember { UserDataRepository(context) }
|
||||
|
||||
var serverUrl by remember { mutableStateOf(TextFieldValue(ApiClient.baseUrl.removeSuffix("/"))) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val savedUrl = userRepo.serverUrl.first()
|
||||
serverUrl = TextFieldValue(savedUrl)
|
||||
}
|
||||
|
||||
val themes = listOf(
|
||||
Triple("default", "StreamFlow", Color(0xFF06B6D4)),
|
||||
Triple("netflix", "Netflix", Color(0xFFE50914)),
|
||||
Triple("apple", "Apple TV+", Color(0xFFFFFFFF))
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.background)
|
||||
.padding(horizontal = 48.dp, vertical = 32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = StreamFlowTheme.typography.displayMedium,
|
||||
modifier = Modifier.padding(bottom = 32.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "CHOOSE THEME",
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color.White.copy(alpha = 0.5f)
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
themes.forEach { (id, name, color) ->
|
||||
val isSelected = currentTheme == id
|
||||
|
||||
Surface(
|
||||
onClick = { onThemeChange(id) },
|
||||
modifier = Modifier.width(200.dp),
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = if (isSelected) Color.White.copy(alpha = 0.1f) else colors.surfaceVariant,
|
||||
focusedContainerColor = Color.White.copy(alpha = 0.15f)
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(Color.Black, RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = name.first().toString(),
|
||||
style = StreamFlowTheme.typography.headlineLarge.copy(color = color)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = name,
|
||||
style = StreamFlowTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
if (isSelected) {
|
||||
Text(
|
||||
text = "✓ Active",
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color(0xFF22C55E)
|
||||
),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
|
||||
Text(
|
||||
text = "SERVER URL",
|
||||
style = StreamFlowTheme.typography.labelSmall.copy(
|
||||
color = Color.White.copy(alpha = 0.5f)
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
BasicTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = { serverUrl = it },
|
||||
textStyle = StreamFlowTheme.typography.titleMedium,
|
||||
cursorBrush = SolidColor(colors.primary),
|
||||
modifier = Modifier
|
||||
.width(400.dp)
|
||||
.background(colors.surfaceVariant, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
|
||||
Surface(
|
||||
onClick = {
|
||||
val url = serverUrl.text.trim()
|
||||
ApiClient.baseUrl = url
|
||||
scope.launch { userRepo.setServerUrl(url) }
|
||||
},
|
||||
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
|
||||
colors = ClickableSurfaceDefaults.colors(
|
||||
containerColor = colors.primary,
|
||||
focusedContainerColor = colors.accent
|
||||
),
|
||||
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
|
||||
) {
|
||||
Text(
|
||||
"Save",
|
||||
style = StreamFlowTheme.typography.labelLarge.copy(color = Color.White),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Enter the IP address and port of your StreamFlow backend server.",
|
||||
style = StreamFlowTheme.typography.bodyMedium,
|
||||
modifier = Modifier.widthIn(max = 500.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
package com.streamflow.tv.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// StreamFlow Default Theme (Cyan/Blue)
|
||||
val StreamFlowPrimary = Color(0xFF06B6D4)
|
||||
val StreamFlowSecondary = Color(0xFF3B82F6)
|
||||
val StreamFlowAccent = Color(0xFF22D3EE)
|
||||
|
||||
// Netflix Theme (Red)
|
||||
val NetflixPrimary = Color(0xFFE50914)
|
||||
val NetflixSecondary = Color(0xFFB81D24)
|
||||
val NetflixAccent = Color(0xFFFF3D3D)
|
||||
|
||||
// Apple TV+ Theme (White/Silver)
|
||||
val ApplePrimary = Color(0xFFFFFFFF)
|
||||
val AppleSecondary = Color(0xFFA1A1AA)
|
||||
val AppleAccent = Color(0xFFD4D4D8)
|
||||
|
||||
// Common
|
||||
val DarkBackground = Color(0xFF141414)
|
||||
val DarkSurface = Color(0xFF1A1A1A)
|
||||
val DarkSurfaceVariant = Color(0xFF262626)
|
||||
val TextPrimary = Color(0xFFFFFFFF)
|
||||
val TextSecondary = Color(0xFF9CA3AF)
|
||||
val TextMuted = Color(0xFF6B7280)
|
||||
val CardBackground = Color(0xFF1E1E1E)
|
||||
val DividerColor = Color(0x1AFFFFFF)
|
||||
package com.streamflow.tv.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// StreamFlow Default Theme (Cyan/Blue)
|
||||
val StreamFlowPrimary = Color(0xFF06B6D4)
|
||||
val StreamFlowSecondary = Color(0xFF3B82F6)
|
||||
val StreamFlowAccent = Color(0xFF22D3EE)
|
||||
|
||||
// Netflix Theme (Red)
|
||||
val NetflixPrimary = Color(0xFFE50914)
|
||||
val NetflixSecondary = Color(0xFFB81D24)
|
||||
val NetflixAccent = Color(0xFFFF3D3D)
|
||||
|
||||
// Apple TV+ Theme (White/Silver)
|
||||
val ApplePrimary = Color(0xFFFFFFFF)
|
||||
val AppleSecondary = Color(0xFFA1A1AA)
|
||||
val AppleAccent = Color(0xFFD4D4D8)
|
||||
|
||||
// Common
|
||||
val DarkBackground = Color(0xFF141414)
|
||||
val DarkSurface = Color(0xFF1A1A1A)
|
||||
val DarkSurfaceVariant = Color(0xFF262626)
|
||||
val TextPrimary = Color(0xFFFFFFFF)
|
||||
val TextSecondary = Color(0xFF9CA3AF)
|
||||
val TextMuted = Color(0xFF6B7280)
|
||||
val CardBackground = Color(0xFF1E1E1E)
|
||||
val DividerColor = Color(0x1AFFFFFF)
|
||||
|
|
|
|||
|
|
@ -1,122 +1,122 @@
|
|||
package com.streamflow.tv.ui.theme
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.tv.material3.*
|
||||
|
||||
data class StreamFlowColors(
|
||||
val primary: Color,
|
||||
val secondary: Color,
|
||||
val accent: Color,
|
||||
val background: Color = DarkBackground,
|
||||
val surface: Color = DarkSurface,
|
||||
val surfaceVariant: Color = DarkSurfaceVariant,
|
||||
val textPrimary: Color = TextPrimary,
|
||||
val textSecondary: Color = TextSecondary,
|
||||
val card: Color = CardBackground,
|
||||
val divider: Color = DividerColor
|
||||
)
|
||||
|
||||
val LocalStreamFlowColors = staticCompositionLocalOf {
|
||||
StreamFlowColors(
|
||||
primary = StreamFlowPrimary,
|
||||
secondary = StreamFlowSecondary,
|
||||
accent = StreamFlowAccent
|
||||
)
|
||||
}
|
||||
|
||||
object StreamFlowTheme {
|
||||
val colors: StreamFlowColors
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalStreamFlowColors.current
|
||||
|
||||
val typography = AppTypography
|
||||
}
|
||||
|
||||
fun streamFlowColors(themeName: String): StreamFlowColors {
|
||||
return when (themeName) {
|
||||
"netflix" -> StreamFlowColors(
|
||||
primary = NetflixPrimary,
|
||||
secondary = NetflixSecondary,
|
||||
accent = NetflixAccent
|
||||
)
|
||||
"apple" -> StreamFlowColors(
|
||||
primary = ApplePrimary,
|
||||
secondary = AppleSecondary,
|
||||
accent = AppleAccent
|
||||
)
|
||||
else -> StreamFlowColors(
|
||||
primary = StreamFlowPrimary,
|
||||
secondary = StreamFlowSecondary,
|
||||
accent = StreamFlowAccent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun StreamFlowTvTheme(
|
||||
themeName: String = "default",
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colors = streamFlowColors(themeName)
|
||||
|
||||
val colorScheme = ColorScheme(
|
||||
primary = colors.primary,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = colors.primary.copy(alpha = 0.3f),
|
||||
onPrimaryContainer = Color.White,
|
||||
secondary = colors.secondary,
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = colors.secondary.copy(alpha = 0.3f),
|
||||
onSecondaryContainer = Color.White,
|
||||
tertiary = colors.accent,
|
||||
onTertiary = Color.Black,
|
||||
tertiaryContainer = colors.accent.copy(alpha = 0.3f),
|
||||
onTertiaryContainer = Color.White,
|
||||
background = colors.background,
|
||||
onBackground = Color.White,
|
||||
surface = colors.surface,
|
||||
onSurface = Color.White,
|
||||
surfaceVariant = colors.surfaceVariant,
|
||||
onSurfaceVariant = Color.White,
|
||||
error = Color.Red,
|
||||
onError = Color.White,
|
||||
errorContainer = Color.Red.copy(alpha = 0.1f),
|
||||
onErrorContainer = Color.Red,
|
||||
border = colors.divider,
|
||||
borderVariant = colors.divider,
|
||||
scrim = Color.Black,
|
||||
inverseSurface = Color.White,
|
||||
inverseOnSurface = Color.Black,
|
||||
inversePrimary = colors.primary,
|
||||
surfaceTint = colors.primary
|
||||
)
|
||||
|
||||
val tvTypography = Typography(
|
||||
displayLarge = AppTypography.displayLarge,
|
||||
displayMedium = AppTypography.displayMedium,
|
||||
displaySmall = AppTypography.displayMedium,
|
||||
headlineLarge = AppTypography.headlineLarge,
|
||||
headlineMedium = AppTypography.headlineMedium,
|
||||
headlineSmall = AppTypography.headlineMedium,
|
||||
titleLarge = AppTypography.titleLarge,
|
||||
titleMedium = AppTypography.titleMedium,
|
||||
titleSmall = AppTypography.titleMedium,
|
||||
bodyLarge = AppTypography.bodyLarge,
|
||||
bodyMedium = AppTypography.bodyMedium,
|
||||
bodySmall = AppTypography.bodyMedium,
|
||||
labelLarge = AppTypography.labelLarge,
|
||||
labelMedium = AppTypography.labelLarge,
|
||||
labelSmall = AppTypography.labelSmall
|
||||
)
|
||||
|
||||
CompositionLocalProvider(LocalStreamFlowColors provides colors) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = tvTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.ui.theme
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.tv.material3.*
|
||||
|
||||
data class StreamFlowColors(
|
||||
val primary: Color,
|
||||
val secondary: Color,
|
||||
val accent: Color,
|
||||
val background: Color = DarkBackground,
|
||||
val surface: Color = DarkSurface,
|
||||
val surfaceVariant: Color = DarkSurfaceVariant,
|
||||
val textPrimary: Color = TextPrimary,
|
||||
val textSecondary: Color = TextSecondary,
|
||||
val card: Color = CardBackground,
|
||||
val divider: Color = DividerColor
|
||||
)
|
||||
|
||||
val LocalStreamFlowColors = staticCompositionLocalOf {
|
||||
StreamFlowColors(
|
||||
primary = StreamFlowPrimary,
|
||||
secondary = StreamFlowSecondary,
|
||||
accent = StreamFlowAccent
|
||||
)
|
||||
}
|
||||
|
||||
object StreamFlowTheme {
|
||||
val colors: StreamFlowColors
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalStreamFlowColors.current
|
||||
|
||||
val typography = AppTypography
|
||||
}
|
||||
|
||||
fun streamFlowColors(themeName: String): StreamFlowColors {
|
||||
return when (themeName) {
|
||||
"netflix" -> StreamFlowColors(
|
||||
primary = NetflixPrimary,
|
||||
secondary = NetflixSecondary,
|
||||
accent = NetflixAccent
|
||||
)
|
||||
"apple" -> StreamFlowColors(
|
||||
primary = ApplePrimary,
|
||||
secondary = AppleSecondary,
|
||||
accent = AppleAccent
|
||||
)
|
||||
else -> StreamFlowColors(
|
||||
primary = StreamFlowPrimary,
|
||||
secondary = StreamFlowSecondary,
|
||||
accent = StreamFlowAccent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
fun StreamFlowTvTheme(
|
||||
themeName: String = "default",
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colors = streamFlowColors(themeName)
|
||||
|
||||
val colorScheme = ColorScheme(
|
||||
primary = colors.primary,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = colors.primary.copy(alpha = 0.3f),
|
||||
onPrimaryContainer = Color.White,
|
||||
secondary = colors.secondary,
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = colors.secondary.copy(alpha = 0.3f),
|
||||
onSecondaryContainer = Color.White,
|
||||
tertiary = colors.accent,
|
||||
onTertiary = Color.Black,
|
||||
tertiaryContainer = colors.accent.copy(alpha = 0.3f),
|
||||
onTertiaryContainer = Color.White,
|
||||
background = colors.background,
|
||||
onBackground = Color.White,
|
||||
surface = colors.surface,
|
||||
onSurface = Color.White,
|
||||
surfaceVariant = colors.surfaceVariant,
|
||||
onSurfaceVariant = Color.White,
|
||||
error = Color.Red,
|
||||
onError = Color.White,
|
||||
errorContainer = Color.Red.copy(alpha = 0.1f),
|
||||
onErrorContainer = Color.Red,
|
||||
border = colors.divider,
|
||||
borderVariant = colors.divider,
|
||||
scrim = Color.Black,
|
||||
inverseSurface = Color.White,
|
||||
inverseOnSurface = Color.Black,
|
||||
inversePrimary = colors.primary,
|
||||
surfaceTint = colors.primary
|
||||
)
|
||||
|
||||
val tvTypography = Typography(
|
||||
displayLarge = AppTypography.displayLarge,
|
||||
displayMedium = AppTypography.displayMedium,
|
||||
displaySmall = AppTypography.displayMedium,
|
||||
headlineLarge = AppTypography.headlineLarge,
|
||||
headlineMedium = AppTypography.headlineMedium,
|
||||
headlineSmall = AppTypography.headlineMedium,
|
||||
titleLarge = AppTypography.titleLarge,
|
||||
titleMedium = AppTypography.titleMedium,
|
||||
titleSmall = AppTypography.titleMedium,
|
||||
bodyLarge = AppTypography.bodyLarge,
|
||||
bodyMedium = AppTypography.bodyMedium,
|
||||
bodySmall = AppTypography.bodyMedium,
|
||||
labelLarge = AppTypography.labelLarge,
|
||||
labelMedium = AppTypography.labelLarge,
|
||||
labelSmall = AppTypography.labelSmall
|
||||
)
|
||||
|
||||
CompositionLocalProvider(LocalStreamFlowColors provides colors) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = tvTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,59 @@
|
|||
package com.streamflow.tv.ui.theme
|
||||
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
object AppTypography {
|
||||
val displayLarge = TextStyle(
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary,
|
||||
letterSpacing = (-0.5).sp
|
||||
)
|
||||
val displayMedium = TextStyle(
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
val headlineLarge = TextStyle(
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
val headlineMedium = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
val titleLarge = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextPrimary
|
||||
)
|
||||
val titleMedium = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextPrimary
|
||||
)
|
||||
val bodyLarge = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = TextSecondary
|
||||
)
|
||||
val bodyMedium = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = TextSecondary
|
||||
)
|
||||
val labelLarge = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextPrimary
|
||||
)
|
||||
val labelSmall = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
package com.streamflow.tv.ui.theme
|
||||
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
object AppTypography {
|
||||
val displayLarge = TextStyle(
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary,
|
||||
letterSpacing = (-0.5).sp
|
||||
)
|
||||
val displayMedium = TextStyle(
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
val headlineLarge = TextStyle(
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
val headlineMedium = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
val titleLarge = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextPrimary
|
||||
)
|
||||
val titleMedium = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextPrimary
|
||||
)
|
||||
val bodyLarge = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = TextSecondary
|
||||
)
|
||||
val bodyMedium = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = TextSecondary
|
||||
)
|
||||
val labelLarge = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextPrimary
|
||||
)
|
||||
val labelSmall = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,45 @@
|
|||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.MovieDetail
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class DetailUiState(
|
||||
val movie: MovieDetail? = null,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val isInMyList: Boolean = false
|
||||
)
|
||||
|
||||
class DetailViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(DetailUiState())
|
||||
val uiState: StateFlow<DetailUiState> = _uiState
|
||||
|
||||
fun loadMovie(slug: String) {
|
||||
android.util.Log.e("DetailVM", "loadMovie($slug) called")
|
||||
viewModelScope.launch {
|
||||
_uiState.value = DetailUiState(isLoading = true)
|
||||
try {
|
||||
val movie = repository.getMovieDetail(slug)
|
||||
android.util.Log.e("DetailVM", "loadMovie success: ${movie.title}, episodes: ${movie.episodes?.size}")
|
||||
_uiState.value = DetailUiState(movie = movie, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("DetailVM", "loadMovie failed", e)
|
||||
_uiState.value = DetailUiState(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load movie details"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleMyList(isInList: Boolean) {
|
||||
_uiState.value = _uiState.value.copy(isInMyList = !isInList)
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.MovieDetail
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class DetailUiState(
|
||||
val movie: MovieDetail? = null,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val isInMyList: Boolean = false
|
||||
)
|
||||
|
||||
class DetailViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(DetailUiState())
|
||||
val uiState: StateFlow<DetailUiState> = _uiState
|
||||
|
||||
fun loadMovie(slug: String) {
|
||||
android.util.Log.e("DetailVM", "loadMovie($slug) called")
|
||||
viewModelScope.launch {
|
||||
_uiState.value = DetailUiState(isLoading = true)
|
||||
try {
|
||||
val movie = repository.getMovieDetail(slug)
|
||||
android.util.Log.e("DetailVM", "loadMovie success: ${movie.title}, episodes: ${movie.episodes?.size}")
|
||||
_uiState.value = DetailUiState(movie = movie, isLoading = false)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("DetailVM", "loadMovie failed", e)
|
||||
_uiState.value = DetailUiState(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load movie details"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleMyList(isInList: Boolean) {
|
||||
_uiState.value = _uiState.value.copy(isInMyList = !isInList)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,144 +1,110 @@
|
|||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class HomeUiState(
|
||||
val heroMovies: List<Movie> = emptyList(),
|
||||
val watchedMovies: List<Movie> = emptyList(),
|
||||
val recommendedMovies: List<Movie> = emptyList(),
|
||||
val categoryMovies: Map<String, List<Movie>> = emptyMap(),
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val currentCategory: String? = null
|
||||
)
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(HomeUiState())
|
||||
val uiState: StateFlow<HomeUiState> = _uiState
|
||||
|
||||
private var userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null
|
||||
|
||||
private val categories = listOf(
|
||||
"phim-le" to "Phim Lẻ",
|
||||
"phim-bo" to "Phim Bộ",
|
||||
"hoat-hinh" to "Hoạt Hình",
|
||||
"tv-shows" to "TV Shows"
|
||||
)
|
||||
|
||||
init {
|
||||
loadHome()
|
||||
}
|
||||
|
||||
fun loadHome(
|
||||
category: String? = null,
|
||||
userRepo: com.streamflow.tv.data.repository.UserDataRepository? = null
|
||||
) {
|
||||
if (userRepo != null) {
|
||||
this.userDataRepository = userRepo
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null, currentCategory = category)
|
||||
try {
|
||||
// Load history if repository is available
|
||||
val history = userRepo?.watchHistory?.first() ?: emptyList()
|
||||
|
||||
if (category != null) {
|
||||
// Load single category
|
||||
val response = repository.getHomeVideos(category)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
heroMovies = response.items.take(5),
|
||||
watchedMovies = history,
|
||||
recommendedMovies = response.items.filter { m -> history.none { it.slug == m.slug } }.shuffled().take(10),
|
||||
categoryMovies = mapOf(
|
||||
categories.find { it.first == category }?.second.orEmpty() to response.items
|
||||
),
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
// Load all categories for home
|
||||
val allMovies = java.util.Collections.synchronizedMap(mutableMapOf<String, List<Movie>>())
|
||||
val allFlattened = java.util.Collections.synchronizedList(mutableListOf<Movie>())
|
||||
|
||||
kotlinx.coroutines.coroutineScope {
|
||||
// 1. Initial categories
|
||||
val categoryTasks = categories.map { (slug, name) ->
|
||||
async {
|
||||
try {
|
||||
val response = repository.getHomeVideos(slug)
|
||||
allMovies[name] = response.items
|
||||
allFlattened.addAll(response.items)
|
||||
response.items
|
||||
} catch (_: Exception) { emptyList<Movie>() }
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch Genres & Countries metadata in parallel
|
||||
val genresDeferred = async { try { repository.getGenres().take(8) } catch (_: Exception) { emptyList() } }
|
||||
val countriesDeferred = async { try { repository.getCountries().take(5) } catch (_: Exception) { emptyList() } }
|
||||
|
||||
val genres = genresDeferred.await()
|
||||
val countries = countriesDeferred.await()
|
||||
|
||||
// 3. Fetch Genre and Country content in parallel
|
||||
val genreTasks = genres.map { genre ->
|
||||
async {
|
||||
try {
|
||||
val response = repository.getHomeVideos(genre.slug)
|
||||
if (response.items.isNotEmpty()) {
|
||||
allMovies["Genre: ${genre.name}"] = response.items
|
||||
allFlattened.addAll(response.items)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class HomeUiState(
|
||||
val heroMovies: List<Movie> = emptyList(),
|
||||
val watchedMovies: List<Movie> = emptyList(),
|
||||
val recommendedMovies: List<Movie> = emptyList(),
|
||||
val categoryMovies: Map<String, List<Movie>> = emptyMap(),
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val currentCategory: String? = null
|
||||
)
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(HomeUiState())
|
||||
val uiState: StateFlow<HomeUiState> = _uiState
|
||||
|
||||
private var userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null
|
||||
|
||||
private val categories = listOf(
|
||||
"phim-le" to "Phim Lẻ",
|
||||
"phim-bo" to "Phim Bộ",
|
||||
"hoat-hinh" to "Hoạt Hình",
|
||||
"tv-shows" to "TV Shows"
|
||||
)
|
||||
|
||||
init {
|
||||
loadHome()
|
||||
}
|
||||
|
||||
fun loadHome(
|
||||
category: String? = null,
|
||||
userRepo: com.streamflow.tv.data.repository.UserDataRepository? = null
|
||||
) {
|
||||
if (userRepo != null) {
|
||||
this.userDataRepository = userRepo
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null, currentCategory = category)
|
||||
try {
|
||||
// Load history if repository is available
|
||||
val history = userRepo?.watchHistory?.first() ?: emptyList()
|
||||
|
||||
if (category != null) {
|
||||
// Load single category
|
||||
val response = repository.getHomeVideos(category)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
heroMovies = response.items.take(5),
|
||||
watchedMovies = history,
|
||||
recommendedMovies = response.items.filter { m -> history.none { it.slug == m.slug } }.shuffled().take(10),
|
||||
categoryMovies = mapOf(
|
||||
categories.find { it.first == category }?.second.orEmpty() to response.items
|
||||
),
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
// Load all categories for home
|
||||
val allMovies = java.util.Collections.synchronizedMap(mutableMapOf<String, List<Movie>>())
|
||||
val allFlattened = java.util.Collections.synchronizedList(mutableListOf<Movie>())
|
||||
|
||||
kotlinx.coroutines.coroutineScope {
|
||||
// Load main categories only (to avoid OOM on TV devices)
|
||||
val categoryTasks = categories.map { (slug, name) ->
|
||||
async {
|
||||
try {
|
||||
val response = repository.getHomeVideos(slug)
|
||||
allMovies[name] = response.items.take(15)
|
||||
allFlattened.addAll(response.items.take(15))
|
||||
response.items
|
||||
} catch (_: Exception) { emptyList<Movie>() }
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for categories
|
||||
categoryTasks.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
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.data.repository.UserDataRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class MyListUiState(
|
||||
val savedMovies: List<Movie> = emptyList(),
|
||||
val watchHistory: List<Movie> = emptyList()
|
||||
)
|
||||
|
||||
class MyListViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val userRepo = UserDataRepository(application)
|
||||
private val _uiState = MutableStateFlow(MyListUiState())
|
||||
val uiState: StateFlow<MyListUiState> = _uiState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
userRepo.myList.collectLatest { list ->
|
||||
_uiState.value = _uiState.value.copy(savedMovies = list)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
userRepo.watchHistory.collectLatest { history ->
|
||||
_uiState.value = _uiState.value.copy(watchHistory = history)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addToMyList(movie: Movie) {
|
||||
viewModelScope.launch { userRepo.addToMyList(movie) }
|
||||
}
|
||||
|
||||
fun removeFromMyList(slug: String) {
|
||||
viewModelScope.launch { userRepo.removeFromMyList(slug) }
|
||||
}
|
||||
|
||||
fun addToHistory(movie: Movie) {
|
||||
viewModelScope.launch { userRepo.addToHistory(movie) }
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.data.repository.UserDataRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class MyListUiState(
|
||||
val savedMovies: List<Movie> = emptyList(),
|
||||
val watchHistory: List<Movie> = emptyList()
|
||||
)
|
||||
|
||||
class MyListViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val userRepo = UserDataRepository(application)
|
||||
private val _uiState = MutableStateFlow(MyListUiState())
|
||||
val uiState: StateFlow<MyListUiState> = _uiState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
userRepo.myList.collectLatest { list ->
|
||||
_uiState.value = _uiState.value.copy(savedMovies = list)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
userRepo.watchHistory.collectLatest { history ->
|
||||
_uiState.value = _uiState.value.copy(watchHistory = history)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addToMyList(movie: Movie) {
|
||||
viewModelScope.launch { userRepo.addToMyList(movie) }
|
||||
}
|
||||
|
||||
fun removeFromMyList(slug: String) {
|
||||
viewModelScope.launch { userRepo.removeFromMyList(slug) }
|
||||
}
|
||||
|
||||
fun addToHistory(movie: Movie) {
|
||||
viewModelScope.launch { userRepo.addToHistory(movie) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,100 +1,100 @@
|
|||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.MovieDetail
|
||||
import com.streamflow.tv.data.model.VideoSource
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PlayerUiState(
|
||||
val movie: MovieDetail? = null,
|
||||
val source: VideoSource? = null,
|
||||
val currentEpisode: Int = 1,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
class PlayerViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(PlayerUiState())
|
||||
val uiState: StateFlow<PlayerUiState> = _uiState
|
||||
|
||||
fun loadPlayer(slug: String, episode: Int = 1) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = PlayerUiState(isLoading = true, currentEpisode = episode)
|
||||
try {
|
||||
val movie = repository.getMovieDetail(slug)
|
||||
_uiState.value = _uiState.value.copy(movie = movie)
|
||||
loadStream(movie, episode)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun changeEpisode(episode: Int) {
|
||||
val movie = _uiState.value.movie ?: return
|
||||
_uiState.value = _uiState.value.copy(currentEpisode = episode, isLoading = true, source = null)
|
||||
viewModelScope.launch {
|
||||
loadStream(movie, episode)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveToHistory(userDataRepository: com.streamflow.tv.data.repository.UserDataRepository) {
|
||||
val movie = _uiState.value.movie ?: return
|
||||
viewModelScope.launch {
|
||||
userDataRepository.addToHistory(movie.toMovie())
|
||||
android.util.Log.e("PlayerViewModel", "Movie saved to history: ${movie.title}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadStream(movie: MovieDetail, episode: Int) {
|
||||
try {
|
||||
val ep = movie.episodes?.find { it.number == episode }
|
||||
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"))) {
|
||||
// Direct HLS URL
|
||||
android.util.Log.e("PlayerViewModel", "Direct HLS URL found: ${ep.url}")
|
||||
_uiState.value = _uiState.value.copy(
|
||||
source = VideoSource(
|
||||
streamUrl = ep.url,
|
||||
resolution = "HD",
|
||||
formatId = "hls"
|
||||
),
|
||||
isLoading = false
|
||||
)
|
||||
} else if (ep != null && ep.url.isNotEmpty()) {
|
||||
// Non-HLS URL — try to extract via backend
|
||||
android.util.Log.e("PlayerViewModel", "Extracting from URL: ${ep.url}")
|
||||
val source = repository.extractVideo(ep.url)
|
||||
android.util.Log.e("PlayerViewModel", "Extraction successful: $source")
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
source = source,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
// No valid episode URL found
|
||||
android.util.Log.e("PlayerViewModel", "No stream URL found for episode $episode")
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "No stream available for episode $episode"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("PlayerViewModel", "Error loading stream", e)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to extract stream"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.MovieDetail
|
||||
import com.streamflow.tv.data.model.VideoSource
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PlayerUiState(
|
||||
val movie: MovieDetail? = null,
|
||||
val source: VideoSource? = null,
|
||||
val currentEpisode: Int = 1,
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
class PlayerViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(PlayerUiState())
|
||||
val uiState: StateFlow<PlayerUiState> = _uiState
|
||||
|
||||
fun loadPlayer(slug: String, episode: Int = 1) {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = PlayerUiState(isLoading = true, currentEpisode = episode)
|
||||
try {
|
||||
val movie = repository.getMovieDetail(slug)
|
||||
_uiState.value = _uiState.value.copy(movie = movie)
|
||||
loadStream(movie, episode)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to load"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun changeEpisode(episode: Int) {
|
||||
val movie = _uiState.value.movie ?: return
|
||||
_uiState.value = _uiState.value.copy(currentEpisode = episode, isLoading = true, source = null)
|
||||
viewModelScope.launch {
|
||||
loadStream(movie, episode)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveToHistory(userDataRepository: com.streamflow.tv.data.repository.UserDataRepository) {
|
||||
val movie = _uiState.value.movie ?: return
|
||||
viewModelScope.launch {
|
||||
userDataRepository.addToHistory(movie.toMovie())
|
||||
android.util.Log.e("PlayerViewModel", "Movie saved to history: ${movie.title}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadStream(movie: MovieDetail, episode: Int) {
|
||||
try {
|
||||
val ep = movie.episodes?.find { it.number == episode }
|
||||
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"))) {
|
||||
// Direct HLS URL
|
||||
android.util.Log.e("PlayerViewModel", "Direct HLS URL found: ${ep.url}")
|
||||
_uiState.value = _uiState.value.copy(
|
||||
source = VideoSource(
|
||||
streamUrl = ep.url,
|
||||
resolution = "HD",
|
||||
formatId = "hls"
|
||||
),
|
||||
isLoading = false
|
||||
)
|
||||
} else if (ep != null && ep.url.isNotEmpty()) {
|
||||
// Non-HLS URL — try to extract via backend
|
||||
android.util.Log.e("PlayerViewModel", "Extracting from URL: ${ep.url}")
|
||||
val source = repository.extractVideo(ep.url)
|
||||
android.util.Log.e("PlayerViewModel", "Extraction successful: $source")
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
source = source,
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
// No valid episode URL found
|
||||
android.util.Log.e("PlayerViewModel", "No stream URL found for episode $episode")
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = "No stream available for episode $episode"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("PlayerViewModel", "Error loading stream", e)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Failed to extract stream"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,39 @@
|
|||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SearchUiState(
|
||||
val query: String = "",
|
||||
val results: List<Movie> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val hasSearched: Boolean = false
|
||||
)
|
||||
|
||||
class SearchViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(SearchUiState())
|
||||
val uiState: StateFlow<SearchUiState> = _uiState
|
||||
|
||||
fun search(query: String) {
|
||||
if (query.isBlank()) return
|
||||
_uiState.value = SearchUiState(query = query, isLoading = true, hasSearched = true)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = repository.searchVideos(query)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
results = response.items,
|
||||
isLoading = false
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.streamflow.tv.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.streamflow.tv.data.model.Movie
|
||||
import com.streamflow.tv.data.repository.MovieRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SearchUiState(
|
||||
val query: String = "",
|
||||
val results: List<Movie> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val hasSearched: Boolean = false
|
||||
)
|
||||
|
||||
class SearchViewModel : ViewModel() {
|
||||
|
||||
private val repository = MovieRepository()
|
||||
private val _uiState = MutableStateFlow(SearchUiState())
|
||||
val uiState: StateFlow<SearchUiState> = _uiState
|
||||
|
||||
fun search(query: String) {
|
||||
if (query.isBlank()) return
|
||||
_uiState.value = SearchUiState(query = query, isLoading = true, hasSearched = true)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = repository.searchVideos(query)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
results = response.items,
|
||||
isLoading = false
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,33 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="320dp"
|
||||
android:height="180dp"
|
||||
android:viewportWidth="320"
|
||||
android:viewportHeight="180">
|
||||
|
||||
<!-- Background -->
|
||||
<path
|
||||
android:pathData="M0,0h320v180H0z"
|
||||
android:fillColor="#141414"/>
|
||||
|
||||
<!-- Gradient accent bar -->
|
||||
<path
|
||||
android:pathData="M0,160h320v20H0z"
|
||||
android:fillColor="#06B6D4"/>
|
||||
|
||||
<!-- Icon circle -->
|
||||
<path
|
||||
android:pathData="M160,75m-30,0a30,30 0,1 1,60 0a30,30 0,1 1,-60 0"
|
||||
android:fillColor="#06B6D4"/>
|
||||
|
||||
<!-- Play triangle -->
|
||||
<path
|
||||
android:pathData="M152,60L172,75L152,90z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
|
||||
<!-- Text: StreamFlow -->
|
||||
<path
|
||||
android:pathData="M95,130h130"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="0.5"/>
|
||||
</vector>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="320dp"
|
||||
android:height="180dp"
|
||||
android:viewportWidth="320"
|
||||
android:viewportHeight="180">
|
||||
|
||||
<!-- Background -->
|
||||
<path
|
||||
android:pathData="M0,0h320v180H0z"
|
||||
android:fillColor="#141414"/>
|
||||
|
||||
<!-- Gradient accent bar -->
|
||||
<path
|
||||
android:pathData="M0,160h320v20H0z"
|
||||
android:fillColor="#06B6D4"/>
|
||||
|
||||
<!-- Icon circle -->
|
||||
<path
|
||||
android:pathData="M160,75m-30,0a30,30 0,1 1,60 0a30,30 0,1 1,-60 0"
|
||||
android:fillColor="#06B6D4"/>
|
||||
|
||||
<!-- Play triangle -->
|
||||
<path
|
||||
android:pathData="M152,60L172,75L152,90z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
|
||||
<!-- Text: StreamFlow -->
|
||||
<path
|
||||
android:pathData="M95,130h130"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="0.5"/>
|
||||
</vector>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
|
||||
<!-- Background rounded rect -->
|
||||
<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:fillColor="#06B6D4"/>
|
||||
|
||||
<!-- Play triangle -->
|
||||
<path
|
||||
android:pathData="M18,12L36,24L18,36z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</vector>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
|
||||
<!-- Background rounded rect -->
|
||||
<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:fillColor="#06B6D4"/>
|
||||
|
||||
<!-- Play triangle -->
|
||||
<path
|
||||
android:pathData="M18,12L36,24L18,36z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</vector>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">StreamFlow</string>
|
||||
</resources>
|
||||
<resources>
|
||||
<string name="app_name">StreamFlow</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.StreamFlowTV" parent="@style/Theme.Leanback">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:backgroundDimEnabled">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.StreamFlowTV" parent="@style/Theme.Leanback">
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
<item name="android:backgroundDimEnabled">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
plugins {
|
||||
id("com.android.application") version "8.2.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
}
|
||||
plugins {
|
||||
id("com.android.application") version "8.2.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,35 @@
|
|||
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
||||
> Task :app:preBuild UP-TO-DATE
|
||||
> Task :app:preDebugBuild UP-TO-DATE
|
||||
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
||||
> Task :app:generateDebugResValues UP-TO-DATE
|
||||
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
||||
> Task :app:generateDebugResources UP-TO-DATE
|
||||
> Task :app:mergeDebugResources UP-TO-DATE
|
||||
> Task :app:packageDebugResources UP-TO-DATE
|
||||
> Task :app:parseDebugLocalResources UP-TO-DATE
|
||||
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
||||
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
||||
> Task :app:processDebugMainManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
||||
> Task :app:processDebugResources UP-TO-DATE
|
||||
|
||||
> 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
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* What went wrong:
|
||||
Execution failed for task ':app:compileDebugKotlin'.
|
||||
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
||||
> Compilation error. See log for more details
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> 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.
|
||||
|
||||
BUILD FAILED in 3s
|
||||
14 actionable tasks: 2 executed, 12 up-to-date
|
||||
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
||||
> Task :app:preBuild UP-TO-DATE
|
||||
> Task :app:preDebugBuild UP-TO-DATE
|
||||
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
||||
> Task :app:generateDebugResValues UP-TO-DATE
|
||||
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
||||
> Task :app:generateDebugResources UP-TO-DATE
|
||||
> Task :app:mergeDebugResources UP-TO-DATE
|
||||
> Task :app:packageDebugResources UP-TO-DATE
|
||||
> Task :app:parseDebugLocalResources UP-TO-DATE
|
||||
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
||||
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
||||
> Task :app:processDebugMainManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
||||
> Task :app:processDebugResources UP-TO-DATE
|
||||
|
||||
> 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
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* What went wrong:
|
||||
Execution failed for task ':app:compileDebugKotlin'.
|
||||
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
||||
> Compilation error. See log for more details
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> 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.
|
||||
|
||||
BUILD FAILED in 3s
|
||||
14 actionable tasks: 2 executed, 12 up-to-date
|
||||
|
|
|
|||
|
|
@ -1,35 +1,35 @@
|
|||
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
||||
> Task :app:preBuild UP-TO-DATE
|
||||
> Task :app:preDebugBuild UP-TO-DATE
|
||||
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
||||
> Task :app:generateDebugResValues UP-TO-DATE
|
||||
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
||||
> Task :app:generateDebugResources UP-TO-DATE
|
||||
> Task :app:mergeDebugResources UP-TO-DATE
|
||||
> Task :app:packageDebugResources UP-TO-DATE
|
||||
> Task :app:parseDebugLocalResources UP-TO-DATE
|
||||
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
||||
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
||||
> Task :app:processDebugMainManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
||||
> Task :app:processDebugResources UP-TO-DATE
|
||||
|
||||
> 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
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* What went wrong:
|
||||
Execution failed for task ':app:compileDebugKotlin'.
|
||||
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
||||
> Compilation error. See log for more details
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> 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.
|
||||
|
||||
BUILD FAILED in 1s
|
||||
14 actionable tasks: 2 executed, 12 up-to-date
|
||||
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
||||
> Task :app:preBuild UP-TO-DATE
|
||||
> Task :app:preDebugBuild UP-TO-DATE
|
||||
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
||||
> Task :app:generateDebugResValues UP-TO-DATE
|
||||
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
||||
> Task :app:generateDebugResources UP-TO-DATE
|
||||
> Task :app:mergeDebugResources UP-TO-DATE
|
||||
> Task :app:packageDebugResources UP-TO-DATE
|
||||
> Task :app:parseDebugLocalResources UP-TO-DATE
|
||||
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
||||
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
||||
> Task :app:processDebugMainManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
||||
> Task :app:processDebugResources UP-TO-DATE
|
||||
|
||||
> 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
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* What went wrong:
|
||||
Execution failed for task ':app:compileDebugKotlin'.
|
||||
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
||||
> Compilation error. See log for more details
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> 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.
|
||||
|
||||
BUILD FAILED in 1s
|
||||
14 actionable tasks: 2 executed, 12 up-to-date
|
||||
|
|
|
|||
|
|
@ -1,37 +1,37 @@
|
|||
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
||||
> Task :app:preBuild UP-TO-DATE
|
||||
> Task :app:preDebugBuild UP-TO-DATE
|
||||
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
||||
> Task :app:generateDebugResValues UP-TO-DATE
|
||||
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
||||
> Task :app:generateDebugResources UP-TO-DATE
|
||||
> Task :app:mergeDebugResources UP-TO-DATE
|
||||
> Task :app:packageDebugResources UP-TO-DATE
|
||||
> Task :app:parseDebugLocalResources UP-TO-DATE
|
||||
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
||||
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
||||
> Task :app:processDebugMainManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
||||
> Task :app:processDebugResources UP-TO-DATE
|
||||
|
||||
> 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: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'
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* What went wrong:
|
||||
Execution failed for task ':app:compileDebugKotlin'.
|
||||
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
||||
> Compilation error. See log for more details
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> 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.
|
||||
|
||||
BUILD FAILED in 1s
|
||||
14 actionable tasks: 2 executed, 12 up-to-date
|
||||
> Task :app:checkKotlinGradlePluginConfigurationErrors
|
||||
> Task :app:preBuild UP-TO-DATE
|
||||
> Task :app:preDebugBuild UP-TO-DATE
|
||||
> Task :app:checkDebugAarMetadata UP-TO-DATE
|
||||
> Task :app:generateDebugResValues UP-TO-DATE
|
||||
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
|
||||
> Task :app:generateDebugResources UP-TO-DATE
|
||||
> Task :app:mergeDebugResources UP-TO-DATE
|
||||
> Task :app:packageDebugResources UP-TO-DATE
|
||||
> Task :app:parseDebugLocalResources UP-TO-DATE
|
||||
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
|
||||
> Task :app:extractDeepLinksDebug UP-TO-DATE
|
||||
> Task :app:processDebugMainManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifest UP-TO-DATE
|
||||
> Task :app:processDebugManifestForPackage UP-TO-DATE
|
||||
> Task :app:processDebugResources UP-TO-DATE
|
||||
|
||||
> 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: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'
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* What went wrong:
|
||||
Execution failed for task ':app:compileDebugKotlin'.
|
||||
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
|
||||
> Compilation error. See log for more details
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> 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.
|
||||
|
||||
BUILD FAILED in 1s
|
||||
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 Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@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 obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@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 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%..
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
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.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" "-javaagent:%APP_HOME%/lib/agents/gradle-instrumentation-agent-8.4.jar"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\lib\gradle-launcher-8.4.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.launcher.GradleMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@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 obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@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 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%..
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
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.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" "-javaagent:%APP_HOME%/lib/agents/gradle-instrumentation-agent-8.4.jar"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\lib\gradle-launcher-8.4.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.launcher.GradleMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
|
|
|
|||
0
android-tv/gradlew
vendored
Normal file → Executable file
188
android-tv/gradlew.bat
vendored
|
|
@ -1,94 +1,94 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@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 obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@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 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
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.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 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 Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 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
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@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 obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@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 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
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.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 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 Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 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
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
|
|
|||
46385
android-tv/logcat.txt
Normal file
2151
android-tv/logcat2.txt
Normal file
|
|
@ -1,18 +1,18 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "StreamFlowTV"
|
||||
include(":app")
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "StreamFlowTV"
|
||||
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)
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(movies)
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +95,7 @@ func (h *Handler) SearchVideos(w http.ResponseWriter, r *http.Request) {
|
|||
return p.Search(query, page)
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(movies)
|
||||
}
|
||||
|
||||
|
|
@ -211,6 +213,7 @@ func (h *Handler) ExtractVideo(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(info)
|
||||
}
|
||||
|
||||
|
|
@ -296,16 +299,18 @@ func (h *Handler) GetMovieDetail(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
if len(primaryMovie.Episodes) > 0 {
|
||||
uniqueEps := make([]models.Episode, 0)
|
||||
seenEpNums := make(map[int]bool)
|
||||
seenEpNums := make(map[string]bool)
|
||||
for _, ep := range primaryMovie.Episodes {
|
||||
if !seenEpNums[ep.Number] {
|
||||
seenEpNums[ep.Number] = true
|
||||
key := fmt.Sprintf("%d-%s", ep.Number, ep.ServerName)
|
||||
if !seenEpNums[key] {
|
||||
seenEpNums[key] = true
|
||||
uniqueEps = append(uniqueEps, ep)
|
||||
}
|
||||
}
|
||||
primaryMovie.Episodes = uniqueEps
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(primaryMovie)
|
||||
}
|
||||
|
||||
|
|
@ -316,6 +321,7 @@ func (h *Handler) GetGenres(w http.ResponseWriter, r *http.Request) {
|
|||
}); ok {
|
||||
genres, err := gp.GetGenres()
|
||||
if err == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(genres)
|
||||
return
|
||||
}
|
||||
|
|
@ -331,6 +337,7 @@ func (h *Handler) GetCountries(w http.ResponseWriter, r *http.Request) {
|
|||
}); ok {
|
||||
countries, err := cp.GetCountries()
|
||||
if err == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(countries)
|
||||
return
|
||||
}
|
||||
|
|
@ -425,21 +432,23 @@ func (h *Handler) mergeMovieMetadata(existing, new *models.RophimMovie) {
|
|||
existing.Quality = new.Quality
|
||||
}
|
||||
|
||||
epMap := make(map[int]int)
|
||||
for i := range existing.Episodes {
|
||||
epMap[existing.Episodes[i].Number] = i
|
||||
epMap := make(map[string]int)
|
||||
for i, ep := range existing.Episodes {
|
||||
key := fmt.Sprintf("%d-%s", ep.Number, ep.ServerName)
|
||||
epMap[key] = i
|
||||
}
|
||||
|
||||
for i := range new.Episodes {
|
||||
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 != "" {
|
||||
existing.Episodes[idx].URL = newEp.URL
|
||||
existing.Episodes[idx].Title = newEp.Title
|
||||
existing.Episodes[idx].ServerName = newEp.ServerName
|
||||
}
|
||||
} else {
|
||||
epMap[newEp.Number] = len(existing.Episodes)
|
||||
epMap[key] = len(existing.Episodes)
|
||||
existing.Episodes = append(existing.Episodes, *newEp)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func RegisterRoutes(r chi.Router, h *Handler) {
|
||||
r.Get("/videos/home", h.GetHomeVideos)
|
||||
r.Get("/videos/search", h.SearchVideos)
|
||||
r.Get("/videos/{slug}", h.GetMovieDetail)
|
||||
r.Post("/extract", h.ExtractVideo)
|
||||
r.Get("/images/proxy", h.ProxyImage)
|
||||
r.Get("/categories/genres", h.GetGenres)
|
||||
r.Get("/categories/countries", h.GetCountries)
|
||||
r.Get("/stream", h.StreamVideo)
|
||||
}
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func RegisterRoutes(r chi.Router, h *Handler) {
|
||||
r.Get("/videos/home", h.GetHomeVideos)
|
||||
r.Get("/videos/search", h.SearchVideos)
|
||||
r.Get("/videos/{slug}", h.GetMovieDetail)
|
||||
r.Post("/extract", h.ExtractVideo)
|
||||
r.Get("/images/proxy", h.ProxyImage)
|
||||
r.Get("/categories/genres", h.GetGenres)
|
||||
r.Get("/categories/countries", h.GetCountries)
|
||||
r.Get("/stream", h.StreamVideo)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +1,85 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"streamflow-backend/internal/models"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func InitDB(dsn string) {
|
||||
var err error
|
||||
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
|
||||
log.Println("Database connection established")
|
||||
|
||||
// Auto-migrate schema
|
||||
err = DB.AutoMigrate(&models.Video{})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to migrate database:", err)
|
||||
}
|
||||
}
|
||||
|
||||
type VideoRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewVideoRepository(db *gorm.DB) *VideoRepository {
|
||||
return &VideoRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *VideoRepository) Create(video *models.Video) error {
|
||||
return r.db.Create(video).Error
|
||||
}
|
||||
|
||||
func (r *VideoRepository) GetByID(id uint) (*models.Video, error) {
|
||||
var video models.Video
|
||||
err := r.db.First(&video, id).Error
|
||||
return &video, err
|
||||
}
|
||||
|
||||
func (r *VideoRepository) GetBySourceURL(url string) (*models.Video, error) {
|
||||
var video models.Video
|
||||
err := r.db.Where("source_url = ?", url).First(&video).Error
|
||||
return &video, err
|
||||
}
|
||||
|
||||
func (r *VideoRepository) Search(query string, limit int) ([]models.Video, error) {
|
||||
var videos []models.Video
|
||||
err := r.db.Where("title LIKE ?", "%"+query+"%").Limit(limit).Find(&videos).Error
|
||||
return videos, err
|
||||
}
|
||||
|
||||
func (r *VideoRepository) GetAll(skip int, limit int) ([]models.Video, error) {
|
||||
var videos []models.Video
|
||||
err := r.db.Offset(skip).Limit(limit).Find(&videos).Error
|
||||
return videos, err
|
||||
}
|
||||
|
||||
func (r *VideoRepository) Update(id uint, updates map[string]interface{}) (*models.Video, error) {
|
||||
var video models.Video
|
||||
result := r.db.First(&video, id)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
err := r.db.Model(&video).Updates(updates).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &video, nil
|
||||
}
|
||||
|
||||
func (r *VideoRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.Video{}, id).Error
|
||||
}
|
||||
package database
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"streamflow-backend/internal/models"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func InitDB(dsn string) {
|
||||
var err error
|
||||
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
|
||||
log.Println("Database connection established")
|
||||
|
||||
// Auto-migrate schema
|
||||
err = DB.AutoMigrate(&models.Video{})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to migrate database:", err)
|
||||
}
|
||||
}
|
||||
|
||||
type VideoRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewVideoRepository(db *gorm.DB) *VideoRepository {
|
||||
return &VideoRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *VideoRepository) Create(video *models.Video) error {
|
||||
return r.db.Create(video).Error
|
||||
}
|
||||
|
||||
func (r *VideoRepository) GetByID(id uint) (*models.Video, error) {
|
||||
var video models.Video
|
||||
err := r.db.First(&video, id).Error
|
||||
return &video, err
|
||||
}
|
||||
|
||||
func (r *VideoRepository) GetBySourceURL(url string) (*models.Video, error) {
|
||||
var video models.Video
|
||||
err := r.db.Where("source_url = ?", url).First(&video).Error
|
||||
return &video, err
|
||||
}
|
||||
|
||||
func (r *VideoRepository) Search(query string, limit int) ([]models.Video, error) {
|
||||
var videos []models.Video
|
||||
err := r.db.Where("title LIKE ?", "%"+query+"%").Limit(limit).Find(&videos).Error
|
||||
return videos, err
|
||||
}
|
||||
|
||||
func (r *VideoRepository) GetAll(skip int, limit int) ([]models.Video, error) {
|
||||
var videos []models.Video
|
||||
err := r.db.Offset(skip).Limit(limit).Find(&videos).Error
|
||||
return videos, err
|
||||
}
|
||||
|
||||
func (r *VideoRepository) Update(id uint, updates map[string]interface{}) (*models.Video, error) {
|
||||
var video models.Video
|
||||
result := r.db.First(&video, id)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
err := r.db.Model(&video).Updates(updates).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &video, nil
|
||||
}
|
||||
|
||||
func (r *VideoRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.Video{}, id).Error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,56 +1,56 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Video metadata model matches SQLAlchemy Video class
|
||||
type Video struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Title string `json:"title" gorm:"index;size:500"`
|
||||
Description string `json:"description"`
|
||||
Thumbnail string `json:"thumbnail" gorm:"size:1000"`
|
||||
SourceURL string `json:"source_url" gorm:"uniqueIndex;size:2000"`
|
||||
Duration int `json:"duration" gorm:"default:0"`
|
||||
Resolution string `json:"resolution" gorm:"size:20"`
|
||||
Category string `json:"category" gorm:"index;size:100"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// RophimMovie represents the scraped movie data
|
||||
type RophimMovie struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"original_title,omitempty"`
|
||||
Slug string `json:"slug"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Backdrop string `json:"backdrop,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Rating string `json:"rating,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Time string `json:"time,omitempty"` // Raw time string
|
||||
Quality string `json:"quality,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Cast []string `json:"cast,omitempty" gorm:"-"`
|
||||
Director string `json:"director,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Episodes []Episode `json:"episodes,omitempty" gorm:"-"`
|
||||
TrailerURL string `json:"trailer_url,omitempty"`
|
||||
}
|
||||
|
||||
type Episode struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
ServerName string `json:"server_name"`
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Video metadata model matches SQLAlchemy Video class
|
||||
type Video struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Title string `json:"title" gorm:"index;size:500"`
|
||||
Description string `json:"description"`
|
||||
Thumbnail string `json:"thumbnail" gorm:"size:1000"`
|
||||
SourceURL string `json:"source_url" gorm:"uniqueIndex;size:2000"`
|
||||
Duration int `json:"duration" gorm:"default:0"`
|
||||
Resolution string `json:"resolution" gorm:"size:20"`
|
||||
Category string `json:"category" gorm:"index;size:100"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// RophimMovie represents the scraped movie data
|
||||
type RophimMovie struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"original_title,omitempty"`
|
||||
Slug string `json:"slug"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Backdrop string `json:"backdrop,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
Rating string `json:"rating,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Time string `json:"time,omitempty"` // Raw time string
|
||||
Quality string `json:"quality,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Cast []string `json:"cast,omitempty" gorm:"-"`
|
||||
Director string `json:"director,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Episodes []Episode `json:"episodes,omitempty" gorm:"-"`
|
||||
TrailerURL string `json:"trailer_url,omitempty"`
|
||||
}
|
||||
|
||||
type Episode struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
ServerName string `json:"server_name"`
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,12 +220,12 @@ func (s *OphimScraper) fetchAndParseList(url string) ([]models.RophimMovie, erro
|
|||
thumb := item.ThumbURL
|
||||
if !strings.HasPrefix(thumb, "http") {
|
||||
// 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
|
||||
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{
|
||||
|
|
@ -273,12 +273,12 @@ func (s *OphimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error)
|
|||
|
||||
thumb := movie.ThumbURL
|
||||
if !strings.HasPrefix(thumb, "http") {
|
||||
thumb = "https://img.ophim1.com/uploads/movies/" + thumb
|
||||
thumb = "https://img.ophim.live/uploads/movies/" + thumb
|
||||
}
|
||||
|
||||
backdrop := movie.PosterURL
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,191 +1,237 @@
|
|||
package scraper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamflow-backend/internal/models"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
func parseEpisodeNumber(title string) int {
|
||||
// e.g "Tập 1", "Tập 01", "Full"
|
||||
t := strings.ToLower(strings.TrimSpace(title))
|
||||
if t == "full" {
|
||||
return 1
|
||||
}
|
||||
t = strings.ReplaceAll(t, "tập ", "")
|
||||
t = strings.ReplaceAll(t, "tap ", "")
|
||||
|
||||
// handle multi-spaces
|
||||
parts := strings.Fields(t)
|
||||
if len(parts) > 0 {
|
||||
num, err := strconv.Atoi(parts[0])
|
||||
if err == nil {
|
||||
return num
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
const Phim30BaseURL = "https://phim30.me"
|
||||
|
||||
type Phim30Scraper struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewPhim30Scraper() *Phim30Scraper {
|
||||
return &Phim30Scraper{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
return p.scrapeMovieList(searchURL)
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) {
|
||||
// e.g. https://phim30.me/the-loai/hanh-dong?page=1
|
||||
catURL := fmt.Sprintf("%s/the-loai/%s?page=%d", Phim30BaseURL, category, page)
|
||||
return p.scrapeMovieList(catURL)
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie, error) {
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var movies []models.RophimMovie
|
||||
|
||||
doc.Find("a[href^='https://phim30.me/phim/']").Each(func(i int, s *goquery.Selection) {
|
||||
href, _ := s.Attr("href")
|
||||
title, _ := s.Attr("title")
|
||||
|
||||
// Remove the base url to get the slug
|
||||
slug := strings.TrimPrefix(href, "https://phim30.me/phim/")
|
||||
|
||||
// Try to find an image child (check data-src for lazy-loaded images)
|
||||
thumb := ""
|
||||
s.Find("img").Each(func(j int, img *goquery.Selection) {
|
||||
src, _ := img.Attr("src")
|
||||
dataSrc, _ := img.Attr("data-src")
|
||||
lazySrc, _ := img.Attr("lazy-src")
|
||||
if dataSrc != "" {
|
||||
thumb = dataSrc
|
||||
} else if lazySrc != "" {
|
||||
thumb = lazySrc
|
||||
} else if src != "" && !strings.Contains(src, "data:image") {
|
||||
thumb = src
|
||||
}
|
||||
})
|
||||
|
||||
if title != "" && slug != "" {
|
||||
movies = append(movies, models.RophimMovie{
|
||||
ID: slug,
|
||||
Slug: slug,
|
||||
Title: title,
|
||||
OriginalTitle: title,
|
||||
Thumbnail: thumb,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Deduplicate movies because a search page might have multiple links to the same movie
|
||||
var uniqueMovies []models.RophimMovie
|
||||
seen := make(map[string]bool)
|
||||
for _, m := range movies {
|
||||
if !seen[m.Slug] {
|
||||
seen[m.Slug] = true
|
||||
uniqueMovies = append(uniqueMovies, m)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueMovies, nil
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
|
||||
targetURL := fmt.Sprintf("%s/phim/%s", Phim30BaseURL, slug)
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
movie := &models.RophimMovie{
|
||||
ID: slug,
|
||||
Slug: slug,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamflow-backend/internal/models"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
func parseEpisodeNumber(title string) int {
|
||||
// e.g "Tập 1", "Tập 01", "Full"
|
||||
t := strings.ToLower(strings.TrimSpace(title))
|
||||
if t == "full" {
|
||||
return 1
|
||||
}
|
||||
t = strings.ReplaceAll(t, "tập ", "")
|
||||
t = strings.ReplaceAll(t, "tap ", "")
|
||||
|
||||
// handle multi-spaces
|
||||
parts := strings.Fields(t)
|
||||
if len(parts) > 0 {
|
||||
num, err := strconv.Atoi(parts[0])
|
||||
if err == nil {
|
||||
return num
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
const Phim30BaseURL = "https://phim30.me"
|
||||
|
||||
type Phim30Scraper struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewPhim30Scraper() *Phim30Scraper {
|
||||
return &Phim30Scraper{
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
return p.scrapeMovieList(searchURL)
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) {
|
||||
if category == "" || category == "home" {
|
||||
homeURL := fmt.Sprintf("%s/?page=%d", Phim30BaseURL, page)
|
||||
return p.scrapeMovieList(homeURL)
|
||||
}
|
||||
|
||||
var path string
|
||||
switch category {
|
||||
case "phim-le", "phim-bo", "phim-sap-chieu":
|
||||
path = fmt.Sprintf("danh-sach/%s", category)
|
||||
default:
|
||||
// Assume everything else is a Genre (e.g., hanh-dong, hoat-hinh, tv-shows)
|
||||
path = fmt.Sprintf("the-loai/%s", category)
|
||||
}
|
||||
|
||||
catURL := fmt.Sprintf("%s/%s?page=%d", Phim30BaseURL, path, page)
|
||||
return p.scrapeMovieList(catURL)
|
||||
}
|
||||
|
||||
func cleanImageUrl(rawURL string) string {
|
||||
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)
|
||||
if len(parts) == 2 {
|
||||
decoded, err := url.QueryUnescape("https" + parts[1])
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie, error) {
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var movies []models.RophimMovie
|
||||
|
||||
doc.Find("a[href^='https://phim30.me/phim/']").Each(func(i int, s *goquery.Selection) {
|
||||
href, _ := s.Attr("href")
|
||||
title, _ := s.Attr("title")
|
||||
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(s.Text())
|
||||
}
|
||||
|
||||
// Remove the base url to get the slug
|
||||
slug := strings.TrimPrefix(href, "https://phim30.me/phim/")
|
||||
|
||||
// Try to find an image child (check data-src for lazy-loaded images)
|
||||
thumb := ""
|
||||
s.Find("img").Each(func(j int, img *goquery.Selection) {
|
||||
src, _ := img.Attr("src")
|
||||
dataSrc, _ := img.Attr("data-src")
|
||||
lazySrc, _ := img.Attr("lazy-src")
|
||||
if dataSrc != "" {
|
||||
thumb = dataSrc
|
||||
} else if lazySrc != "" {
|
||||
thumb = lazySrc
|
||||
} else if src != "" && !strings.Contains(src, "data:image") {
|
||||
thumb = src
|
||||
}
|
||||
})
|
||||
|
||||
if title != "" && slug != "" && !strings.Contains(slug, "the-loai") && !strings.Contains(slug, "quoc-gia") && !strings.Contains(slug, "nam-phat-hanh") {
|
||||
movies = append(movies, models.RophimMovie{
|
||||
ID: slug,
|
||||
Slug: slug,
|
||||
Title: title,
|
||||
OriginalTitle: title,
|
||||
Thumbnail: cleanImageUrl(thumb),
|
||||
Backdrop: cleanImageUrl(thumb),
|
||||
Provider: "Phim30.me",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Deduplicate movies because a search page might have multiple links to the same movie
|
||||
var uniqueMovies []models.RophimMovie
|
||||
seen := make(map[string]bool)
|
||||
for _, m := range movies {
|
||||
if !seen[m.Slug] {
|
||||
seen[m.Slug] = true
|
||||
uniqueMovies = append(uniqueMovies, m)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueMovies, nil
|
||||
}
|
||||
|
||||
func (p *Phim30Scraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
|
||||
targetURL := fmt.Sprintf("%s/phim/%s", Phim30BaseURL, slug)
|
||||
req, err := http.NewRequest("GET", targetURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
movie := &models.RophimMovie{
|
||||
ID: slug,
|
||||
Slug: slug,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import "streamflow-backend/internal/models"
|
||||
|
||||
type MovieProvider interface {
|
||||
GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error)
|
||||
GetMovieDetail(slug string) (*models.RophimMovie, error)
|
||||
Search(query string, page int) ([]models.RophimMovie, error)
|
||||
}
|
||||
package scraper
|
||||
|
||||
import "streamflow-backend/internal/models"
|
||||
|
||||
type MovieProvider interface {
|
||||
GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error)
|
||||
GetMovieDetail(slug string) (*models.RophimMovie, error)
|
||||
Search(query string, page int) ([]models.RophimMovie, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,246 +1,246 @@
|
|||
package scraper
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamflow-backend/internal/models"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
const BaseURL = "https://phimmoichill.network"
|
||||
|
||||
type RophimScraper struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewRophimScraper() *RophimScraper {
|
||||
// Create custom client to handle SSL constraints if needed, similar to Python's ssl_context
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
return &RophimScraper{client: client}
|
||||
}
|
||||
|
||||
func (s *RophimScraper) fetchDocument(url string) (*goquery.Document, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
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("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
|
||||
req.Header.Set("Referer", BaseURL)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
return goquery.NewDocumentFromReader(resp.Body)
|
||||
}
|
||||
|
||||
func (s *RophimScraper) GetHomepageMovies(page int, limit int) ([]models.RophimMovie, error) {
|
||||
url := fmt.Sprintf("%s/danh-sach/phim-le", BaseURL)
|
||||
if page > 1 {
|
||||
url = fmt.Sprintf("%s/danh-sach/phim-le/page/%d", BaseURL, page)
|
||||
}
|
||||
|
||||
doc, err := s.fetchDocument(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.parseMovieGrid(doc, limit), nil
|
||||
}
|
||||
|
||||
func (s *RophimScraper) Search(query string, limit int) ([]models.RophimMovie, error) {
|
||||
url := fmt.Sprintf("%s/tim-kiem?keyword=%s", BaseURL, query)
|
||||
doc, err := s.fetchDocument(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.parseMovieGrid(doc, limit), nil
|
||||
}
|
||||
|
||||
func (s *RophimScraper) parseMovieGrid(doc *goquery.Document, limit int) []models.RophimMovie {
|
||||
var movies []models.RophimMovie
|
||||
|
||||
doc.Find(".myui-vodlist__box").EachWithBreak(func(i int, s *goquery.Selection) bool {
|
||||
if i >= limit {
|
||||
return false
|
||||
}
|
||||
|
||||
link := s.Find("a.myui-vodlist__thumb")
|
||||
if link.Length() == 0 {
|
||||
link = s.Find("a[href*='/phim/']")
|
||||
}
|
||||
if link.Length() == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
href, _ := link.Attr("href")
|
||||
slug := extractSlug(href)
|
||||
if slug == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
title, _ := link.Attr("title")
|
||||
if title == "" {
|
||||
title = s.Find("h4.title a").Text()
|
||||
}
|
||||
|
||||
style, _ := link.Attr("style")
|
||||
thumbnail := extractThumbnail(style)
|
||||
if thumbnail == "" {
|
||||
thumbnail, _ = s.Find("img").Attr("src")
|
||||
}
|
||||
|
||||
quality := s.Find(".pic-tag").Text()
|
||||
if quality == "" {
|
||||
quality = "HD"
|
||||
}
|
||||
|
||||
engTitle := s.Find(".text-muted").Text()
|
||||
|
||||
movie := models.RophimMovie{
|
||||
ID: slug,
|
||||
Title: strings.TrimSpace(title),
|
||||
OriginalTitle: strings.TrimSpace(engTitle),
|
||||
Slug: slug,
|
||||
Thumbnail: normalizeURL(thumbnail),
|
||||
Quality: strings.TrimSpace(quality),
|
||||
Category: "movies", // Default
|
||||
}
|
||||
movies = append(movies, movie)
|
||||
return true
|
||||
})
|
||||
|
||||
return movies
|
||||
}
|
||||
|
||||
func (s *RophimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
|
||||
url := fmt.Sprintf("%s/phim/%s", BaseURL, slug)
|
||||
doc, err := s.fetchDocument(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.parseMovieDetail(doc, slug), nil
|
||||
}
|
||||
|
||||
func (s *RophimScraper) parseMovieDetail(doc *goquery.Document, slug string) *models.RophimMovie {
|
||||
title := doc.Find("h1.movie-title").Text()
|
||||
if title == "" {
|
||||
title = doc.Find("h1").Text()
|
||||
}
|
||||
|
||||
description := doc.Find("meta[name='description']").AttrOr("content", "")
|
||||
if description == "" {
|
||||
description = doc.Find(".description, .content, .film-description").Text()
|
||||
}
|
||||
|
||||
poster := doc.Find("meta[property='og:image']").AttrOr("content", "")
|
||||
|
||||
// Parse Info (Year, Country, etc) - simplified for brevity
|
||||
var year int
|
||||
doc.Find(".movie-info li, .film-info li").Each(func(i int, s *goquery.Selection) {
|
||||
text := s.Text()
|
||||
if strings.Contains(text, "Năm") || strings.Contains(text, "Year") {
|
||||
re := regexp.MustCompile(`\d{4}`)
|
||||
if match := re.FindString(text); match != "" {
|
||||
year, _ = strconv.Atoi(match)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Parse Episodes
|
||||
var episodes []models.Episode
|
||||
doc.Find("a[href*='/tap-'], a[href*='episode'], .episode-list a").Each(func(i int, s *goquery.Selection) {
|
||||
href, _ := s.Attr("href")
|
||||
text := strings.TrimSpace(s.Text())
|
||||
|
||||
re := regexp.MustCompile(`tap-(\d+)`)
|
||||
match := re.FindStringSubmatch(href)
|
||||
if len(match) > 1 {
|
||||
epNum, _ := strconv.Atoi(match[1])
|
||||
episodes = append(episodes, models.Episode{
|
||||
Number: epNum,
|
||||
Title: text,
|
||||
URL: normalizeURL(href),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// De-duplicate episodes
|
||||
seen := make(map[int]bool)
|
||||
var uniqueEpisodes []models.Episode
|
||||
for _, ep := range episodes {
|
||||
if !seen[ep.Number] {
|
||||
seen[ep.Number] = true
|
||||
uniqueEpisodes = append(uniqueEpisodes, ep)
|
||||
}
|
||||
}
|
||||
|
||||
return &models.RophimMovie{
|
||||
ID: slug,
|
||||
Title: strings.TrimSpace(title),
|
||||
Slug: slug,
|
||||
Thumbnail: normalizeURL(poster),
|
||||
Description: strings.TrimSpace(description),
|
||||
Year: year,
|
||||
Episodes: uniqueEpisodes,
|
||||
Category: "movies",
|
||||
}
|
||||
}
|
||||
|
||||
func extractSlug(url string) string {
|
||||
re := regexp.MustCompile(`/phim/([^/?#]+)`)
|
||||
matches := re.FindStringSubmatch(url)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
// Fallback
|
||||
parts := strings.Split(url, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractThumbnail(style string) string {
|
||||
re := regexp.MustCompile(`url\(([^)]+)\)`)
|
||||
matches := re.FindStringSubmatch(style)
|
||||
if len(matches) > 1 {
|
||||
return strings.Trim(matches[1], "'\"")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeURL(url string) string {
|
||||
if url == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(url, "//") {
|
||||
return "https:" + url
|
||||
}
|
||||
if strings.HasPrefix(url, "/") {
|
||||
return BaseURL + url
|
||||
}
|
||||
return url
|
||||
}
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamflow-backend/internal/models"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
const BaseURL = "https://phimmoichill.network"
|
||||
|
||||
type RophimScraper struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewRophimScraper() *RophimScraper {
|
||||
// Create custom client to handle SSL constraints if needed, similar to Python's ssl_context
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
return &RophimScraper{client: client}
|
||||
}
|
||||
|
||||
func (s *RophimScraper) fetchDocument(url string) (*goquery.Document, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
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("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
|
||||
req.Header.Set("Referer", BaseURL)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
return goquery.NewDocumentFromReader(resp.Body)
|
||||
}
|
||||
|
||||
func (s *RophimScraper) GetHomepageMovies(page int, limit int) ([]models.RophimMovie, error) {
|
||||
url := fmt.Sprintf("%s/danh-sach/phim-le", BaseURL)
|
||||
if page > 1 {
|
||||
url = fmt.Sprintf("%s/danh-sach/phim-le/page/%d", BaseURL, page)
|
||||
}
|
||||
|
||||
doc, err := s.fetchDocument(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.parseMovieGrid(doc, limit), nil
|
||||
}
|
||||
|
||||
func (s *RophimScraper) Search(query string, limit int) ([]models.RophimMovie, error) {
|
||||
url := fmt.Sprintf("%s/tim-kiem?keyword=%s", BaseURL, query)
|
||||
doc, err := s.fetchDocument(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.parseMovieGrid(doc, limit), nil
|
||||
}
|
||||
|
||||
func (s *RophimScraper) parseMovieGrid(doc *goquery.Document, limit int) []models.RophimMovie {
|
||||
var movies []models.RophimMovie
|
||||
|
||||
doc.Find(".myui-vodlist__box").EachWithBreak(func(i int, s *goquery.Selection) bool {
|
||||
if i >= limit {
|
||||
return false
|
||||
}
|
||||
|
||||
link := s.Find("a.myui-vodlist__thumb")
|
||||
if link.Length() == 0 {
|
||||
link = s.Find("a[href*='/phim/']")
|
||||
}
|
||||
if link.Length() == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
href, _ := link.Attr("href")
|
||||
slug := extractSlug(href)
|
||||
if slug == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
title, _ := link.Attr("title")
|
||||
if title == "" {
|
||||
title = s.Find("h4.title a").Text()
|
||||
}
|
||||
|
||||
style, _ := link.Attr("style")
|
||||
thumbnail := extractThumbnail(style)
|
||||
if thumbnail == "" {
|
||||
thumbnail, _ = s.Find("img").Attr("src")
|
||||
}
|
||||
|
||||
quality := s.Find(".pic-tag").Text()
|
||||
if quality == "" {
|
||||
quality = "HD"
|
||||
}
|
||||
|
||||
engTitle := s.Find(".text-muted").Text()
|
||||
|
||||
movie := models.RophimMovie{
|
||||
ID: slug,
|
||||
Title: strings.TrimSpace(title),
|
||||
OriginalTitle: strings.TrimSpace(engTitle),
|
||||
Slug: slug,
|
||||
Thumbnail: normalizeURL(thumbnail),
|
||||
Quality: strings.TrimSpace(quality),
|
||||
Category: "movies", // Default
|
||||
}
|
||||
movies = append(movies, movie)
|
||||
return true
|
||||
})
|
||||
|
||||
return movies
|
||||
}
|
||||
|
||||
func (s *RophimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
|
||||
url := fmt.Sprintf("%s/phim/%s", BaseURL, slug)
|
||||
doc, err := s.fetchDocument(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.parseMovieDetail(doc, slug), nil
|
||||
}
|
||||
|
||||
func (s *RophimScraper) parseMovieDetail(doc *goquery.Document, slug string) *models.RophimMovie {
|
||||
title := doc.Find("h1.movie-title").Text()
|
||||
if title == "" {
|
||||
title = doc.Find("h1").Text()
|
||||
}
|
||||
|
||||
description := doc.Find("meta[name='description']").AttrOr("content", "")
|
||||
if description == "" {
|
||||
description = doc.Find(".description, .content, .film-description").Text()
|
||||
}
|
||||
|
||||
poster := doc.Find("meta[property='og:image']").AttrOr("content", "")
|
||||
|
||||
// Parse Info (Year, Country, etc) - simplified for brevity
|
||||
var year int
|
||||
doc.Find(".movie-info li, .film-info li").Each(func(i int, s *goquery.Selection) {
|
||||
text := s.Text()
|
||||
if strings.Contains(text, "Năm") || strings.Contains(text, "Year") {
|
||||
re := regexp.MustCompile(`\d{4}`)
|
||||
if match := re.FindString(text); match != "" {
|
||||
year, _ = strconv.Atoi(match)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Parse Episodes
|
||||
var episodes []models.Episode
|
||||
doc.Find("a[href*='/tap-'], a[href*='episode'], .episode-list a").Each(func(i int, s *goquery.Selection) {
|
||||
href, _ := s.Attr("href")
|
||||
text := strings.TrimSpace(s.Text())
|
||||
|
||||
re := regexp.MustCompile(`tap-(\d+)`)
|
||||
match := re.FindStringSubmatch(href)
|
||||
if len(match) > 1 {
|
||||
epNum, _ := strconv.Atoi(match[1])
|
||||
episodes = append(episodes, models.Episode{
|
||||
Number: epNum,
|
||||
Title: text,
|
||||
URL: normalizeURL(href),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// De-duplicate episodes
|
||||
seen := make(map[int]bool)
|
||||
var uniqueEpisodes []models.Episode
|
||||
for _, ep := range episodes {
|
||||
if !seen[ep.Number] {
|
||||
seen[ep.Number] = true
|
||||
uniqueEpisodes = append(uniqueEpisodes, ep)
|
||||
}
|
||||
}
|
||||
|
||||
return &models.RophimMovie{
|
||||
ID: slug,
|
||||
Title: strings.TrimSpace(title),
|
||||
Slug: slug,
|
||||
Thumbnail: normalizeURL(poster),
|
||||
Description: strings.TrimSpace(description),
|
||||
Year: year,
|
||||
Episodes: uniqueEpisodes,
|
||||
Category: "movies",
|
||||
}
|
||||
}
|
||||
|
||||
func extractSlug(url string) string {
|
||||
re := regexp.MustCompile(`/phim/([^/?#]+)`)
|
||||
matches := re.FindStringSubmatch(url)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
// Fallback
|
||||
parts := strings.Split(url, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractThumbnail(style string) string {
|
||||
re := regexp.MustCompile(`url\(([^)]+)\)`)
|
||||
matches := re.FindStringSubmatch(style)
|
||||
if len(matches) > 1 {
|
||||
return strings.Trim(matches[1], "'\"")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeURL(url string) string {
|
||||
if url == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(url, "//") {
|
||||
return "https:" + url
|
||||
}
|
||||
if strings.HasPrefix(url, "/") {
|
||||
return BaseURL + url
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,96 +1,127 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type VideoInfo struct {
|
||||
Title string `json:"title"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Duration int `json:"duration"`
|
||||
StreamURL string `json:"url"` // yt-dlp JSON key is 'url'
|
||||
FormatID string `json:"format_id"`
|
||||
Resolution string `json:"resolution"` // Custom field
|
||||
Ext string `json:"ext"`
|
||||
}
|
||||
|
||||
type VideoExtractor struct{}
|
||||
|
||||
func NewVideoExtractor() *VideoExtractor {
|
||||
return &VideoExtractor{}
|
||||
}
|
||||
|
||||
func (e *VideoExtractor) Extract(url string, quality string) (*VideoInfo, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Check for custom extractors
|
||||
if strings.Contains(url, "phim30.me") {
|
||||
// Currently returning the URL as-is, letting yt-dlp attempt extraction
|
||||
// or allowing the frontend iframe to handle it directly if it's embeddable
|
||||
}
|
||||
|
||||
// Build format selector
|
||||
formatSelector := "bestvideo+bestaudio/best"
|
||||
if quality != "" {
|
||||
height := strings.Replace(quality, "p", "", -1)
|
||||
formatSelector = fmt.Sprintf("bestvideo[height<=%s]+bestaudio/best[height<=%s]/best", height, height)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--dump-json",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--format", formatSelector,
|
||||
url,
|
||||
}
|
||||
|
||||
// Check for local yt-dlp.exe
|
||||
ytDlpCmd := "yt-dlp"
|
||||
// Only on windows for simplicity or check OS
|
||||
if _, err := os.Stat("yt-dlp.exe"); err == nil {
|
||||
path, _ := filepath.Abs("yt-dlp.exe")
|
||||
ytDlpCmd = path
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, ytDlpCmd, args...)
|
||||
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
|
||||
}
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
)
|
||||
|
||||
type VideoInfo struct {
|
||||
Title string `json:"title"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Duration int `json:"duration"`
|
||||
StreamURL string `json:"url"` // yt-dlp JSON key is 'url'
|
||||
FormatID string `json:"format_id"`
|
||||
Resolution string `json:"resolution"` // Custom field
|
||||
Ext string `json:"ext"`
|
||||
}
|
||||
|
||||
type VideoExtractor struct{}
|
||||
|
||||
func NewVideoExtractor() *VideoExtractor {
|
||||
return &VideoExtractor{}
|
||||
}
|
||||
|
||||
func (e *VideoExtractor) Extract(url string, quality string) (*VideoInfo, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Check for custom extractors
|
||||
if strings.Contains(url, "phim30.me") {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create phim30 request: %v", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch phim30 page: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse phim30 page: %v", err)
|
||||
}
|
||||
|
||||
streamURL, _ := doc.Find("[data-movie-player-src-value]").Attr("data-movie-player-src-value")
|
||||
if streamURL != "" {
|
||||
return &VideoInfo{
|
||||
StreamURL: streamURL,
|
||||
Resolution: "unknown",
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("could not find stream URL on phim30 page")
|
||||
}
|
||||
|
||||
// Build format selector
|
||||
formatSelector := "bestvideo+bestaudio/best"
|
||||
if quality != "" {
|
||||
height := strings.Replace(quality, "p", "", -1)
|
||||
formatSelector = fmt.Sprintf("bestvideo[height<=%s]+bestaudio/best[height<=%s]/best", height, height)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--dump-json",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--format", formatSelector,
|
||||
url,
|
||||
}
|
||||
|
||||
// Check for local yt-dlp.exe
|
||||
ytDlpCmd := "yt-dlp"
|
||||
// Only on windows for simplicity or check OS
|
||||
if _, err := os.Stat("yt-dlp.exe"); err == nil {
|
||||
path, _ := filepath.Abs("yt-dlp.exe")
|
||||
ytDlpCmd = path
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, ytDlpCmd, args...)
|
||||
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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
const CacheDir = "cache/images"
|
||||
|
||||
type ImageService struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewImageService() *ImageService {
|
||||
os.MkdirAll(CacheDir, 0755)
|
||||
|
||||
// Use custom transport to skip SSL verification
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
|
||||
return &ImageService{
|
||||
client: &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ImageService) GetProxiedImage(url string, width int) ([]byte, string, error) {
|
||||
hash := md5.Sum([]byte(fmt.Sprintf("%s_%d", url, width)))
|
||||
cacheKey := fmt.Sprintf("%x.jpg", hash)
|
||||
cachePath := filepath.Join(CacheDir, cacheKey)
|
||||
|
||||
// Check cache
|
||||
if _, err := os.Stat(cachePath); err == nil {
|
||||
data, err := os.ReadFile(cachePath)
|
||||
if err == nil {
|
||||
return data, "image/jpeg", nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch with custom request to set headers
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
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("Referer", "https://ophim1.com/")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, "", fmt.Errorf("image fetch failed: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Decode
|
||||
var img image.Image
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
|
||||
switch contentType {
|
||||
case "image/jpeg":
|
||||
img, err = jpeg.Decode(resp.Body)
|
||||
case "image/png":
|
||||
img, err = png.Decode(resp.Body)
|
||||
default:
|
||||
// Attempt agnostic decode
|
||||
img, _, err = image.Decode(resp.Body)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decode error: %v", err)
|
||||
}
|
||||
|
||||
// Resize if needed
|
||||
if width > 0 && img.Bounds().Dx() > width {
|
||||
bounds := img.Bounds()
|
||||
ratio := float64(width) / float64(bounds.Dx())
|
||||
height := int(float64(bounds.Dy()) * ratio)
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil)
|
||||
img = dst
|
||||
}
|
||||
|
||||
// Encode to JPEG
|
||||
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)
|
||||
}
|
||||
|
||||
jpegData := buf.Bytes()
|
||||
|
||||
// Write cache
|
||||
os.WriteFile(cachePath, jpegData, 0644)
|
||||
|
||||
return jpegData, "image/jpeg", nil
|
||||
}
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
const CacheDir = "cache/images"
|
||||
|
||||
type ImageService struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewImageService() *ImageService {
|
||||
os.MkdirAll(CacheDir, 0755)
|
||||
|
||||
// Use custom transport to skip SSL verification
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
|
||||
return &ImageService{
|
||||
client: &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ImageService) GetProxiedImage(url string, width int) ([]byte, string, error) {
|
||||
hash := md5.Sum([]byte(fmt.Sprintf("%s_%d", url, width)))
|
||||
cacheKey := fmt.Sprintf("%x.jpg", hash)
|
||||
cachePath := filepath.Join(CacheDir, cacheKey)
|
||||
|
||||
// Check cache
|
||||
if _, err := os.Stat(cachePath); err == nil {
|
||||
data, err := os.ReadFile(cachePath)
|
||||
if err == nil {
|
||||
return data, "image/jpeg", nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch with custom request to set headers
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
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("Referer", "https://ophim1.com/")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("GetProxiedImage fetch error: %v\n", err)
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
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")
|
||||
|
||||
switch contentType {
|
||||
case "image/jpeg":
|
||||
img, err = jpeg.Decode(resp.Body)
|
||||
case "image/png":
|
||||
img, err = png.Decode(resp.Body)
|
||||
default:
|
||||
// Attempt agnostic decode
|
||||
img, _, err = image.Decode(resp.Body)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("GetProxiedImage decode error: %v for content-type: %s and url: %s\n", err, contentType, url)
|
||||
return nil, "", fmt.Errorf("decode error: %v", err)
|
||||
}
|
||||
|
||||
// Resize if needed
|
||||
if width > 0 && img.Bounds().Dx() > width {
|
||||
bounds := img.Bounds()
|
||||
ratio := float64(width) / float64(bounds.Dx())
|
||||
height := int(float64(bounds.Dy()) * ratio)
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil)
|
||||
img = dst
|
||||
}
|
||||
|
||||
// Encode to JPEG
|
||||
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)
|
||||
}
|
||||
|
||||
jpegData := buf.Bytes()
|
||||
|
||||
// Write cache
|
||||
os.WriteFile(cachePath, jpegData, 0644)
|
||||
|
||||
return jpegData, "image/jpeg", nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,137 +1,137 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
TMDBBaseURL = "https://api.themoviedb.org/3"
|
||||
TMDBImageBaseURL = "https://image.tmdb.org/t/p"
|
||||
)
|
||||
|
||||
type TMDBService struct {
|
||||
client *http.Client
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewTMDBService() *TMDBService {
|
||||
return &TMDBService{
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
apiKey: os.Getenv("TMDB_API_KEY"),
|
||||
}
|
||||
}
|
||||
|
||||
type TMDBMovieResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
}
|
||||
|
||||
type TMDBSearchResponse struct {
|
||||
Results []TMDBMovieResult `json:"results"`
|
||||
}
|
||||
|
||||
type TMDBMovieDetails struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
Runtime int `json:"runtime"`
|
||||
Budget int64 `json:"budget"`
|
||||
Revenue int64 `json:"revenue"`
|
||||
Tagline string `json:"tagline"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
Credits struct {
|
||||
Cast []struct {
|
||||
Name string `json:"name"`
|
||||
Character string `json:"character"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
} `json:"cast"`
|
||||
Crew []struct {
|
||||
Name string `json:"name"`
|
||||
Job string `json:"job"`
|
||||
} `json:"crew"`
|
||||
} `json:"credits"`
|
||||
}
|
||||
|
||||
func (s *TMDBService) SearchMovie(title string, year int) (*TMDBMovieResult, error) {
|
||||
if s.apiKey == "" {
|
||||
return nil, fmt.Errorf("TMDB_API_KEY not set")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("api_key", s.apiKey)
|
||||
params.Add("query", title)
|
||||
params.Add("language", "en-US")
|
||||
if year > 0 {
|
||||
params.Add("year", fmt.Sprintf("%d", year))
|
||||
}
|
||||
|
||||
resp, err := s.client.Get(fmt.Sprintf("%s/search/movie?%s", TMDBBaseURL, params.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp TMDBSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(searchResp.Results) > 0 {
|
||||
return &searchResp.Results[0], nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetMovieDetails(tmdbID int) (*TMDBMovieDetails, error) {
|
||||
if s.apiKey == "" {
|
||||
return nil, fmt.Errorf("TMDB_API_KEY not set")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("api_key", s.apiKey)
|
||||
params.Add("append_to_response", "credits")
|
||||
params.Add("language", "en-US")
|
||||
|
||||
resp, err := s.client.Get(fmt.Sprintf("%s/movie/%d?%s", TMDBBaseURL, tmdbID, params.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var details TMDBMovieDetails
|
||||
if err := json.NewDecoder(resp.Body).Decode(&details); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &details, nil
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetPosterURL(path string, size string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if size == "" {
|
||||
size = "w500"
|
||||
}
|
||||
return fmt.Sprintf("%s/%s%s", TMDBImageBaseURL, size, path)
|
||||
}
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
TMDBBaseURL = "https://api.themoviedb.org/3"
|
||||
TMDBImageBaseURL = "https://image.tmdb.org/t/p"
|
||||
)
|
||||
|
||||
type TMDBService struct {
|
||||
client *http.Client
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewTMDBService() *TMDBService {
|
||||
return &TMDBService{
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
apiKey: os.Getenv("TMDB_API_KEY"),
|
||||
}
|
||||
}
|
||||
|
||||
type TMDBMovieResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
}
|
||||
|
||||
type TMDBSearchResponse struct {
|
||||
Results []TMDBMovieResult `json:"results"`
|
||||
}
|
||||
|
||||
type TMDBMovieDetails struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
Runtime int `json:"runtime"`
|
||||
Budget int64 `json:"budget"`
|
||||
Revenue int64 `json:"revenue"`
|
||||
Tagline string `json:"tagline"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
Credits struct {
|
||||
Cast []struct {
|
||||
Name string `json:"name"`
|
||||
Character string `json:"character"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
} `json:"cast"`
|
||||
Crew []struct {
|
||||
Name string `json:"name"`
|
||||
Job string `json:"job"`
|
||||
} `json:"crew"`
|
||||
} `json:"credits"`
|
||||
}
|
||||
|
||||
func (s *TMDBService) SearchMovie(title string, year int) (*TMDBMovieResult, error) {
|
||||
if s.apiKey == "" {
|
||||
return nil, fmt.Errorf("TMDB_API_KEY not set")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("api_key", s.apiKey)
|
||||
params.Add("query", title)
|
||||
params.Add("language", "en-US")
|
||||
if year > 0 {
|
||||
params.Add("year", fmt.Sprintf("%d", year))
|
||||
}
|
||||
|
||||
resp, err := s.client.Get(fmt.Sprintf("%s/search/movie?%s", TMDBBaseURL, params.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp TMDBSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(searchResp.Results) > 0 {
|
||||
return &searchResp.Results[0], nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetMovieDetails(tmdbID int) (*TMDBMovieDetails, error) {
|
||||
if s.apiKey == "" {
|
||||
return nil, fmt.Errorf("TMDB_API_KEY not set")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("api_key", s.apiKey)
|
||||
params.Add("append_to_response", "credits")
|
||||
params.Add("language", "en-US")
|
||||
|
||||
resp, err := s.client.Get(fmt.Sprintf("%s/movie/%d?%s", TMDBBaseURL, tmdbID, params.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var details TMDBMovieDetails
|
||||
if err := json.NewDecoder(resp.Body).Decode(&details); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &details, nil
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetPosterURL(path string, size string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if size == "" {
|
||||
size = "w500"
|
||||
}
|
||||
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:
|
||||
streamflow:
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3.9
|
||||
image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v6
|
||||
container_name: streamflow
|
||||
platform: linux/amd64
|
||||
ports:
|
||||
|
|
@ -12,10 +12,11 @@ services:
|
|||
- PORT=8000
|
||||
- TZ=Asia/Ho_Chi_Minh
|
||||
volumes:
|
||||
# Synology: Use relative path for data persistence
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: [ "CMD", "wget", "-q", "--spider", "http://localhost:8000/api/health" ]
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
|
|
|||
1271
frontend-react/package-lock.json
generated
|
|
@ -1,6 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
|
|||