Compare commits

...

8 commits
v3.9 ... main

Author SHA1 Message Date
vndangkhoa
064377d7dd build: Update Dockerfile for v6 and fix TypeScript errors
Some checks failed
StreamFlow CI/CD / Backend Tests (push) Failing after 3s
StreamFlow CI/CD / Backend Lint (push) Failing after 2s
StreamFlow CI/CD / Frontend Tests (push) Failing after 2s
StreamFlow CI/CD / Docker Build (push) Has been skipped
StreamFlow CI/CD / Android TV Build (push) Failing after 1s
StreamFlow CI/CD / Docker Publish (push) Failing after 5s
- Update Dockerfile to use linux/amd64 platform consistently
- Fix unused parameter warnings in Hero.tsx, MovieCard.tsx
- Fix useCallback import issues in useWatchProgress.ts
- Update docker-compose.yml to use v6 tag
- Update README.md with Synology NAS deployment instructions
- Add episode progress tracking documentation
2026-05-07 07:38:39 +07:00
vndangkhoa
0819a1beca feat: Add episode progress tracking and fix image URLs
Some checks failed
StreamFlow CI/CD / Backend Tests (push) Failing after 23s
StreamFlow CI/CD / Backend Lint (push) Failing after 2s
StreamFlow CI/CD / Frontend Tests (push) Failing after 3s
StreamFlow CI/CD / Android TV Build (push) Failing after 1s
StreamFlow CI/CD / Docker Build (push) Has been skipped
StreamFlow CI/CD / Docker Publish (push) Failing after 1m55s
- Add useWatchProgress hook for saving watch progress
- Auto-save progress every 5 seconds and on pause
- Seek to saved position (minus 20s) when returning
- Add Continue Watching section with progress bars
- Fix ophim image URLs (img.ophim.live)
- Remove broken wsrv.nl proxy dependency
- Add episode badge and progress bar to MovieCard
2026-05-06 21:06:05 +07:00
vndangkhoa
3009f94fe9 Release v4: Cleanup and refactoring
Some checks failed
StreamFlow CI/CD / Backend Tests (push) Has been cancelled
StreamFlow CI/CD / Backend Lint (push) Has been cancelled
StreamFlow CI/CD / Frontend Tests (push) Has been cancelled
StreamFlow CI/CD / Android TV Build (push) Has been cancelled
StreamFlow CI/CD / Docker Build (push) Has been cancelled
StreamFlow CI/CD / Docker Publish (push) Has been cancelled
2026-03-03 07:55:27 +07:00
vndangkhoa
9b2339b85d docs: release version 3.9.2
Some checks are pending
StreamFlow CI/CD / Backend Tests (push) Waiting to run
StreamFlow CI/CD / Backend Lint (push) Waiting to run
StreamFlow CI/CD / Frontend Tests (push) Waiting to run
StreamFlow CI/CD / Android TV Build (push) Waiting to run
StreamFlow CI/CD / Docker Build (push) Blocked by required conditions
StreamFlow CI/CD / Docker Publish (push) Blocked by required conditions
2026-03-02 07:35:05 +07:00
vndangkhoa
22229153b9 update android tv apk with production url
Some checks are pending
StreamFlow CI/CD / Backend Tests (push) Waiting to run
StreamFlow CI/CD / Backend Lint (push) Waiting to run
StreamFlow CI/CD / Frontend Tests (push) Waiting to run
StreamFlow CI/CD / Android TV Build (push) Waiting to run
StreamFlow CI/CD / Docker Build (push) Blocked by required conditions
StreamFlow CI/CD / Docker Publish (push) Blocked by required conditions
2026-03-02 07:34:15 +07:00
vndangkhoa
f8be75bd81 v3.9.1: update docker-compose and deploy script for kv-netflix registry
Some checks failed
StreamFlow CI/CD / Backend Tests (push) Waiting to run
StreamFlow CI/CD / Backend Lint (push) Waiting to run
StreamFlow CI/CD / Frontend Tests (push) Waiting to run
StreamFlow CI/CD / Android TV Build (push) Waiting to run
StreamFlow CI/CD / Docker Build (push) Blocked by required conditions
StreamFlow CI/CD / Docker Publish (push) Blocked by required conditions
Release APKs / Build TV APK (push) Has been cancelled
Release APKs / Build Mobile APK (push) Has been cancelled
Release APKs / Create Release (push) Has been cancelled
2026-03-01 13:53:14 +07:00
vndangkhoa
69308bf696 v3.9.2: Fix Android TV OOM crash + backend Content-Type headers
- Backend: Add Content-Type: application/json to all JSON API endpoints
- Android TV: Reduce HomeViewModel memory usage (load 4 categories only, limit 15 items each)
- Android TV: Prevent OOM kill on TV devices with limited RAM
- Updated APK, docker-compose, health endpoint to v3.9.2
2026-03-01 11:34:51 +07:00
vndangkhoa
fbe89e14fd v3.9.1: bump docker-compose tag 2026-02-28 18:50:55 +07:00
138 changed files with 56071 additions and 8020 deletions

View file

@ -1,49 +1,45 @@
# Stage 1: Build Image (Frontend) # Stage 1: Build Frontend
FROM node:20-alpine AS frontend-builder FROM --platform=linux/amd64 node:20-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend-react/package*.json ./ COPY frontend-react/package*.json ./
RUN npm install RUN npm install
COPY frontend-react/ . COPY frontend-react/ .
RUN npm run build RUN npm run build
# Stage 2: Build Image (Backend) # Stage 2: Build Backend for linux/amd64
FROM golang:1.24-alpine AS backend-builder FROM --platform=linux/amd64 golang:1.24-alpine AS backend-builder
WORKDIR /app/backend WORKDIR /app/backend
# Install build dependencies
RUN apk add --no-cache gcc musl-dev COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/go.mod backend/go.sum ./
RUN go mod download COPY backend/ .
# Build static binary for Linux amd64
COPY backend/ . RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o server cmd/server/main.go
# Build static binary for Linux amd64
RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o server cmd/server/main.go # Stage 3: Final Image (linux/amd64 only for Synology NAS)
FROM --platform=linux/amd64 alpine:latest
# Stage 3: Final Image WORKDIR /app
FROM alpine:latest
WORKDIR /app # Install runtime dependencies
RUN apk add --no-cache sqlite ca-certificates tzdata
# Install runtime dependencies (sqlite + yt-dlp for video extraction fallback)
RUN apk add --no-cache sqlite ca-certificates tzdata python3 py3-pip && \ # Copy backend binary
pip3 install --break-system-packages yt-dlp COPY --from=backend-builder /app/backend/server .
# Copy backend binary # Copy frontend build to the expected static directory
COPY --from=backend-builder /app/backend/server . COPY --from=frontend-builder /app/frontend/dist ./dist
# Copy frontend build to the expected static directory # Create data directory for SQLite database
# The backend expects ../frontend-react/dist relative to itself, or we configure it. RUN mkdir -p /app/data
# Let's align with the standard deployment structure: /app/server and /app/dist
COPY --from=frontend-builder /app/frontend/dist ./dist # Environment variables
ENV PORT=8000
# Create data directory ENV DATABASE_URL=/app/data/streamflow.db
RUN mkdir -p data ENV TZ=Asia/Ho_Chi_Minh
# Environment variables # Expose port
ENV PORT=8000 EXPOSE 8000
ENV DATABASE_URL=/app/data/streamflow.db
# Start server
# Expose port CMD ["./server"]
EXPOSE 8000
# Start server
CMD ["./server"]

View file

@ -1,4 +1,4 @@
# StreamFlow V3.9 # kv-netflix V6
A high-performance video streaming web application with a pure Go backend and modern React + Tailwind frontend. A high-performance video streaming web application with a pure Go backend and modern React + Tailwind frontend.
@ -10,6 +10,7 @@ A high-performance video streaming web application with a pure Go backend and mo
- **HLS Streaming** - Native HLS playback with proxy support - **HLS Streaming** - Native HLS playback with proxy support
- **Android TV** - Native TV app with D-pad controls and 10s skip - **Android TV** - Native TV app with D-pad controls and 10s skip
- **PWA Support** - Install as a progressive web app - **PWA Support** - Install as a progressive web app
- **Episode Progress Tracking** - Auto-save progress, continue watching with seek
- **Docker Ready** - Multi-stage build for Synology NAS (linux/amd64) - **Docker Ready** - Multi-stage build for Synology NAS (linux/amd64)
## Tech Stack ## Tech Stack
@ -23,21 +24,45 @@ A high-performance video streaming web application with a pure Go backend and mo
## Quick Start ## Quick Start
### Docker (Recommended) ### Docker (Recommended for Synology NAS)
**Prerequisites:**
- Synology NAS with Container Manager (Docker) installed
- SSH access enabled (optional, for CLI) or use Container Manager GUI
**Option 1: Container Manager GUI (Recommended for Synology)**
1. Open **Container Manager** on your Synology NAS
2. Go to **Registry** tab and add your Forgejo registry:
- Registry URL: `git.khoavo.myds.me`
- Username: `vndangkhoa`
- Password: `Thieugia19`
3. Search for `vndangkhoa/kv-netflix` and download `v6` tag
4. Create a new container:
- **Image**: `git.khoavo.myds.me/vndangkhoa/kv-netflix:v6`
- **Container name**: `streamflow`
- **Network**: Bridge mode, map port `3478` (local) → `8000` (container)
- **Environment**: Add `TZ=Asia/Ho_Chi_Minh`
- **Volume**: Create folder `docker/streamflow/data` on NAS, map to `/app/data`
- **Restart policy**: `Unless stopped`
5. Start the container
**Option 2: Docker Compose (SSH/CLI)**
Create `docker-compose.yml` on your NAS:
```yaml ```yaml
# docker-compose.yml
version: '3.8' version: '3.8'
services: services:
streamflow: streamflow:
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3.9 image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v6
container_name: streamflow container_name: streamflow
platform: linux/amd64 platform: linux/amd64
ports: ports:
- "3478:8000" - "3478:8000"
environment: environment:
- DATABASE_URL=/app/data/streamflow.db - DATABASE_URL=/app/data/streamflow.db
- PORT=8000
- TZ=Asia/Ho_Chi_Minh - TZ=Asia/Ho_Chi_Minh
volumes: volumes:
- ./data:/app/data - ./data:/app/data
@ -51,7 +76,14 @@ services:
``` ```
```bash ```bash
# Login to registry first
docker login git.khoavo.myds.me -u vndangkhoa -p Thieugia19
# Start container
docker-compose up -d docker-compose up -d
# Check logs
docker-compose logs -f
``` ```
Access at: `http://YOUR_NAS_IP:3478` Access at: `http://YOUR_NAS_IP:3478`
@ -119,7 +151,26 @@ Streamflow/
## Changelog ## Changelog
### v3.9 (Current) ### v6 (Current)
- Episode progress tracking with auto-save (every 5s + on pause)
- Continue Watching section with progress bars
- Seek to saved position minus 20 seconds on return
- Fixed ophim image URLs (migrated to img.ophim.live)
- Removed broken wsrv.nl proxy dependency
- Episode badge and progress bar in MovieCard
- Pushed to Forgejo: `git.khoavo.myds.me/vndangkhoa/kv-netflix:v6`
- Docker multi-stage build optimized for Synology NAS (linux/amd64)
### v4
- Deployed v4 to Forgejo and Docker Registry
- Refactored frontend and cleaned up repository
### v3.9.2
- Fixed Android TV local IP issue by replacing it with production backend URL
- Rebuilt Android TV APK and updated the frontend static bundle
### v3.9.1
- Fix Android TV OOM crash + backend Content-Type headers
- Bundled Android TV APK with the webapp for direct download - Bundled Android TV APK with the webapp for direct download
- Verified D-pad navigation on Android TV app - Verified D-pad navigation on Android TV app

BIN
android-tv/adb_logs.txt Normal file

Binary file not shown.

View file

@ -1,88 +1,88 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
} }
android { android {
namespace = "com.streamflow.tv" namespace = "com.streamflow.tv"
compileSdk = 34 compileSdk = 34
defaultConfig { defaultConfig {
applicationId = "com.streamflow.tv" applicationId = "com.streamflow.tv"
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 37 versionCode = 37
versionName = "3.7.0" versionName = "3.7.0"
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = false
isShrinkResources = false isShrinkResources = false
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
} }
} }
buildFeatures { buildFeatures {
compose = true compose = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.5.8" kotlinCompilerExtensionVersion = "1.5.8"
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = "17" jvmTarget = "17"
} }
} }
dependencies { dependencies {
// Compose for TV // Compose for TV
implementation("androidx.tv:tv-foundation:1.0.0-alpha11") implementation("androidx.tv:tv-foundation:1.0.0-alpha11")
implementation("androidx.tv:tv-material:1.0.0") implementation("androidx.tv:tv-material:1.0.0")
// Core Compose // Core Compose
implementation(platform("androidx.compose:compose-bom:2024.01.00")) implementation(platform("androidx.compose:compose-bom:2024.01.00"))
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.activity:activity-compose:1.8.2") implementation("androidx.activity:activity-compose:1.8.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation("androidx.navigation:navigation-compose:2.7.6") implementation("androidx.navigation:navigation-compose:2.7.6")
// ExoPlayer (Media3) // ExoPlayer (Media3)
implementation("androidx.media3:media3-exoplayer:1.2.1") implementation("androidx.media3:media3-exoplayer:1.2.1")
implementation("androidx.media3:media3-exoplayer-hls:1.2.1") implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
implementation("androidx.media3:media3-ui:1.2.1") implementation("androidx.media3:media3-ui:1.2.1")
implementation("androidx.media3:media3-session:1.2.1") implementation("androidx.media3:media3-session:1.2.1")
// Networking // Networking
implementation("com.squareup.retrofit2:retrofit:2.9.0") implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-moshi:2.9.0") implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
implementation("com.squareup.moshi:moshi-kotlin:1.15.0") implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Image loading // Image loading
implementation("io.coil-kt:coil-compose:2.5.0") implementation("io.coil-kt:coil-compose:2.5.0")
// DataStore // DataStore
implementation("androidx.datastore:datastore-preferences:1.0.0") implementation("androidx.datastore:datastore-preferences:1.0.0")
// Core Android TV // Core Android TV
implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.leanback:leanback:1.0.0") implementation("androidx.leanback:leanback:1.0.0")
// Debug // Debug
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
} }

View file

@ -1,54 +1,54 @@
# ProGuard rules for StreamFlow TV # ProGuard rules for StreamFlow TV
# Keep all app classes (safety net) # Keep all app classes (safety net)
-keep class com.streamflow.tv.** { *; } -keep class com.streamflow.tv.** { *; }
-keepclassmembers class com.streamflow.tv.** { *; } -keepclassmembers class com.streamflow.tv.** { *; }
# Moshi # Moshi
-keep class com.squareup.moshi.** { *; } -keep class com.squareup.moshi.** { *; }
-keepclassmembers class * { -keepclassmembers class * {
@com.squareup.moshi.Json <fields>; @com.squareup.moshi.Json <fields>;
} }
-keepclassmembers class * { -keepclassmembers class * {
@com.squareup.moshi.JsonClass <fields>; @com.squareup.moshi.JsonClass <fields>;
} }
# Kotlin Metadata (critical for Moshi reflection adapter) # Kotlin Metadata (critical for Moshi reflection adapter)
-keep class kotlin.Metadata { *; } -keep class kotlin.Metadata { *; }
-keepattributes RuntimeVisibleAnnotations -keepattributes RuntimeVisibleAnnotations
-keepattributes RuntimeInvisibleAnnotations -keepattributes RuntimeInvisibleAnnotations
-keepattributes *Annotation* -keepattributes *Annotation*
# Retrofit # Retrofit
-dontwarn retrofit2.** -dontwarn retrofit2.**
-keep class retrofit2.** { *; } -keep class retrofit2.** { *; }
-keepattributes Signature -keepattributes Signature
-keepattributes Exceptions -keepattributes Exceptions
-keepclassmembers,allowshrinking,allowobfuscation interface * { -keepclassmembers,allowshrinking,allowobfuscation interface * {
@retrofit2.http.* <methods>; @retrofit2.http.* <methods>;
} }
# OkHttp # OkHttp
-dontwarn okhttp3.** -dontwarn okhttp3.**
-dontwarn okio.** -dontwarn okio.**
-keep class okhttp3.** { *; } -keep class okhttp3.** { *; }
-keep class okio.** { *; } -keep class okio.** { *; }
# Kotlin Coroutines # Kotlin Coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembers class kotlinx.** { -keepclassmembers class kotlinx.** {
volatile <fields>; volatile <fields>;
} }
# Coil # Coil
-dontwarn coil.** -dontwarn coil.**
-keep class coil.** { *; } -keep class coil.** { *; }
# AndroidX Compose # AndroidX Compose
-keep class androidx.compose.** { *; } -keep class androidx.compose.** { *; }
-dontwarn androidx.compose.** -dontwarn androidx.compose.**
# ExoPlayer / Media3 # ExoPlayer / Media3
-keep class androidx.media3.** { *; } -keep class androidx.media3.** { *; }
-dontwarn androidx.media3.** -dontwarn androidx.media3.**

View file

@ -1,43 +1,43 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-feature <uses-feature
android:name="android.software.leanback" android:name="android.software.leanback"
android:required="false" /> android:required="false" />
<uses-feature <uses-feature
android:name="android.hardware.touchscreen" android:name="android.hardware.touchscreen"
android:required="false" /> android:required="false" />
<application <application
android:name=".StreamFlowApp" android:name=".StreamFlowApp"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:banner="@drawable/app_banner" android:banner="@drawable/app_banner"
android:theme="@style/Theme.StreamFlowTV" android:theme="@style/Theme.StreamFlowTV"
android:supportsRtl="true" android:supportsRtl="true"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:screenOrientation="landscape"> android:screenOrientation="landscape">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="streamflow" android:host="player" /> <data android:scheme="streamflow" android:host="player" />
</intent-filter> </intent-filter>
</activity> </activity>
</application> </application>
</manifest> </manifest>

View file

@ -1,172 +1,172 @@
package com.streamflow.tv package com.streamflow.tv
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.streamflow.tv.data.api.ApiClient import com.streamflow.tv.data.api.ApiClient
import com.streamflow.tv.data.repository.UserDataRepository import com.streamflow.tv.data.repository.UserDataRepository
import com.streamflow.tv.ui.components.SideNavRail import com.streamflow.tv.ui.components.SideNavRail
import com.streamflow.tv.ui.screens.* import com.streamflow.tv.ui.screens.*
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import com.streamflow.tv.ui.theme.StreamFlowTvTheme import com.streamflow.tv.ui.theme.StreamFlowTvTheme
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d("MainActivity", "onCreate started") Log.d("MainActivity", "onCreate started")
setContent { setContent {
StreamFlowTvApp() StreamFlowTvApp()
} }
} }
} }
@Composable @Composable
fun StreamFlowTvApp() { fun StreamFlowTvApp() {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val userRepo = remember { UserDataRepository(context) } val userRepo = remember { UserDataRepository(context) }
val navController = rememberNavController() val navController = rememberNavController()
var currentTheme by remember { mutableStateOf("default") } var currentTheme by remember { mutableStateOf("default") }
var selectedNavId by remember { mutableStateOf("home") } var selectedNavId by remember { mutableStateOf("home") }
// Load persisted settings // Load persisted settings
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { try {
currentTheme = userRepo.theme.first() currentTheme = userRepo.theme.first()
val serverUrl = userRepo.serverUrl.first() val serverUrl = userRepo.serverUrl.first()
/*if (serverUrl.isNotBlank()) { if (serverUrl.isNotBlank()) {
ApiClient.baseUrl = serverUrl ApiClient.baseUrl = serverUrl
}*/
Log.d("StreamFlowTvApp", "Settings loaded: theme=$currentTheme, url=$serverUrl")
} catch (e: Exception) {
Log.e("StreamFlowTvApp", "Error loading settings", e)
}
}
StreamFlowTvTheme(themeName = currentTheme) {
val colors = StreamFlowTheme.colors
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val showSideNav = currentRoute != null && !currentRoute.startsWith("player")
Row(
modifier = Modifier
.fillMaxSize()
.background(colors.background)
) {
// Side Navigation
if (showSideNav) {
SideNavRail(
selectedId = selectedNavId,
onNavigate = { item ->
selectedNavId = item.id
navController.navigate(item.route) {
popUpTo("home") { saveState = true }
launchSingleTop = true
restoreState = true
}
}
)
} }
Log.d("StreamFlowTvApp", "Settings loaded: theme=$currentTheme, url=$serverUrl")
// Main content } catch (e: Exception) {
Box(modifier = Modifier.weight(1f)) { Log.e("StreamFlowTvApp", "Error loading settings", e)
NavHost( }
navController = navController, }
startDestination = "home"
) { StreamFlowTvTheme(themeName = currentTheme) {
composable("home") { val colors = StreamFlowTheme.colors
HomeScreen(
onMovieClick = { slug -> val navBackStackEntry by navController.currentBackStackEntryAsState()
navController.navigate("detail/$slug") val currentRoute = navBackStackEntry?.destination?.route
}, val showSideNav = currentRoute != null && !currentRoute.startsWith("player")
userDataRepository = userRepo
) Row(
} modifier = Modifier
.fillMaxSize()
composable( .background(colors.background)
"home/{category}", ) {
arguments = listOf(navArgument("category") { type = NavType.StringType }) // Side Navigation
) { entry -> if (showSideNav) {
HomeScreen( SideNavRail(
onMovieClick = { slug -> navController.navigate("detail/$slug") }, selectedId = selectedNavId,
category = entry.arguments?.getString("category"), onNavigate = { item ->
userDataRepository = userRepo selectedNavId = item.id
) navController.navigate(item.route) {
} popUpTo("home") { saveState = true }
launchSingleTop = true
composable( restoreState = true
"detail/{slug}", }
arguments = listOf(navArgument("slug") { type = NavType.StringType }) }
) { entry -> )
val slug = entry.arguments?.getString("slug") ?: return@composable }
DetailScreen(
slug = slug, // Main content
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") }, Box(modifier = Modifier.weight(1f)) {
onBack = { navController.popBackStack() } NavHost(
) navController = navController,
} startDestination = "home"
) {
composable( composable("home") {
"player/{slug}/{episode}", HomeScreen(
arguments = listOf( onMovieClick = { slug ->
navArgument("slug") { type = NavType.StringType }, navController.navigate("detail/$slug")
navArgument("episode") { type = NavType.IntType; defaultValue = 1 } },
), userDataRepository = userRepo
deepLinks = listOf(androidx.navigation.navDeepLink { uriPattern = "streamflow://player/{slug}/{episode}" }) )
) { entry -> }
val slug = entry.arguments?.getString("slug")
val episode = entry.arguments?.getInt("episode") ?: 1 composable(
Log.d("StreamFlowNav", "Navigating to player: slug=$slug, episode=$episode") "home/{category}",
if (slug == null) { arguments = listOf(navArgument("category") { type = NavType.StringType })
return@composable ) { entry ->
} HomeScreen(
PlayerScreen( onMovieClick = { slug -> navController.navigate("detail/$slug") },
slug = slug, category = entry.arguments?.getString("category"),
episode = episode, userDataRepository = userRepo
userDataRepository = userRepo )
) }
}
composable(
composable("search") { "detail/{slug}",
SearchScreen( arguments = listOf(navArgument("slug") { type = NavType.StringType })
onMovieClick = { slug -> navController.navigate("detail/$slug") } ) { entry ->
) val slug = entry.arguments?.getString("slug") ?: return@composable
} DetailScreen(
slug = slug,
composable("mylist") { onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
MyListScreen( onBack = { navController.popBackStack() }
onMovieClick = { slug -> navController.navigate("detail/$slug") } )
) }
}
composable(
composable("settings") { "player/{slug}/{episode}",
SettingsScreen( arguments = listOf(
currentTheme = currentTheme, navArgument("slug") { type = NavType.StringType },
onThemeChange = { theme -> navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
currentTheme = theme ),
scope.launch { userRepo.setTheme(theme) } deepLinks = listOf(androidx.navigation.navDeepLink { uriPattern = "streamflow://player/{slug}/{episode}" })
} ) { entry ->
) val slug = entry.arguments?.getString("slug")
} val episode = entry.arguments?.getInt("episode") ?: 1
} Log.d("StreamFlowNav", "Navigating to player: slug=$slug, episode=$episode")
} if (slug == null) {
} return@composable
} }
} PlayerScreen(
slug = slug,
episode = episode,
userDataRepository = userRepo
)
}
composable("search") {
SearchScreen(
onMovieClick = { slug -> navController.navigate("detail/$slug") }
)
}
composable("mylist") {
MyListScreen(
onMovieClick = { slug -> navController.navigate("detail/$slug") }
)
}
composable("settings") {
SettingsScreen(
currentTheme = currentTheme,
onThemeChange = { theme ->
currentTheme = theme
scope.launch { userRepo.setTheme(theme) }
}
)
}
}
}
}
}
}

View file

@ -1,30 +1,30 @@
package com.streamflow.tv package com.streamflow.tv
import android.app.Application import android.app.Application
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.memory.MemoryCache import coil.memory.MemoryCache
class StreamFlowApp : Application(), ImageLoaderFactory { class StreamFlowApp : Application(), ImageLoaderFactory {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
} }
override fun newImageLoader(): ImageLoader { override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this) return ImageLoader.Builder(this)
.memoryCache { .memoryCache {
MemoryCache.Builder(this) MemoryCache.Builder(this)
.maxSizePercent(0.25) .maxSizePercent(0.25)
.build() .build()
} }
.diskCache { .diskCache {
DiskCache.Builder() DiskCache.Builder()
.directory(this.cacheDir.resolve("image_cache")) .directory(this.cacheDir.resolve("image_cache"))
.maxSizePercent(0.02) .maxSizePercent(0.02)
.build() .build()
} }
.respectCacheHeaders(false) // Often needed for some CDNs .respectCacheHeaders(false) // Often needed for some CDNs
.build() .build()
} }
} }

View file

@ -1,71 +1,71 @@
package com.streamflow.tv.data.api package com.streamflow.tv.data.api
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
object ApiClient { object ApiClient {
// Default base URL for testing // Default base URL for testing
// Change this to your production API when ready // Change this to your production API when ready
// var baseUrl: String = "https://nf.khoavo.myds.me" // private var _baseUrl: String = "http://10.0.2.2:8000/"
private var _baseUrl: String = "http://10.0.2.2:3478/" private var _baseUrl: String = "https://nf.khoavo.myds.me/"
var baseUrl: String var baseUrl: String
get() = _baseUrl get() = _baseUrl
set(value) { set(value) {
_baseUrl = if (value.endsWith("/")) value else "$value/" _baseUrl = if (value.endsWith("/")) value else "$value/"
synchronized(this) { synchronized(this) {
_api = null // Reset to rebuild _api = null // Reset to rebuild
} }
} }
private val moshi: Moshi = Moshi.Builder() private val moshi: Moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory()) .addLast(KotlinJsonAdapterFactory())
.build() .build()
private val userAgentInterceptor = Interceptor { chain -> private val userAgentInterceptor = Interceptor { chain ->
val request = chain.request().newBuilder() val request = chain.request().newBuilder()
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.build() .build()
chain.proceed(request) chain.proceed(request)
} }
private val okHttpClient: OkHttpClient = OkHttpClient.Builder() private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS) .connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS)
.addInterceptor(userAgentInterceptor) .addInterceptor(userAgentInterceptor)
.addInterceptor( .addInterceptor(
HttpLoggingInterceptor().apply { HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.HEADERS level = HttpLoggingInterceptor.Level.HEADERS
} }
) )
.build() .build()
private var _api: StreamFlowApi? = null private var _api: StreamFlowApi? = null
val api: StreamFlowApi val api: StreamFlowApi
get() { get() {
return synchronized(this) { return synchronized(this) {
if (_api == null) { if (_api == null) {
_api = Retrofit.Builder() _api = Retrofit.Builder()
.baseUrl(_baseUrl) .baseUrl(_baseUrl)
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi)) .addConverterFactory(MoshiConverterFactory.create(moshi))
.build() .build()
.create(StreamFlowApi::class.java) .create(StreamFlowApi::class.java)
} }
_api!! _api!!
} }
} }
fun imageProxyUrl(url: String, width: Int = 400): String { fun imageProxyUrl(url: String, width: Int = 400): String {
val base = _baseUrl.removeSuffix("/") val base = _baseUrl.removeSuffix("/")
return "$base/api/images/proxy?url=${java.net.URLEncoder.encode(url, "UTF-8")}&width=$width" return "$base/api/images/proxy?url=${java.net.URLEncoder.encode(url, "UTF-8")}&width=$width"
} }
} }

View file

@ -1,35 +1,35 @@
package com.streamflow.tv.data.api package com.streamflow.tv.data.api
import com.streamflow.tv.data.model.* import com.streamflow.tv.data.model.*
import retrofit2.http.* import retrofit2.http.*
interface StreamFlowApi { interface StreamFlowApi {
@GET("api/videos/home") @GET("api/videos/home")
suspend fun getHomeVideos( suspend fun getHomeVideos(
@Query("category") category: String? = null, @Query("category") category: String? = null,
@Query("page") page: Int = 1 @Query("page") page: Int = 1
): List<Movie> ): List<Movie>
@GET("api/videos/search") @GET("api/videos/search")
suspend fun searchVideos( suspend fun searchVideos(
@Query("q") query: String, @Query("q") query: String,
@Query("page") page: Int = 1 @Query("page") page: Int = 1
): List<Movie> ): List<Movie>
@GET("api/videos/{slug}") @GET("api/videos/{slug}")
suspend fun getMovieDetail( suspend fun getMovieDetail(
@Path("slug") slug: String @Path("slug") slug: String
): MovieDetailResponse ): MovieDetailResponse
@POST("api/extract") @POST("api/extract")
suspend fun extractVideo( suspend fun extractVideo(
@Body request: ExtractRequest @Body request: ExtractRequest
): VideoSource ): VideoSource
@GET("api/categories/genres") @GET("api/categories/genres")
suspend fun getGenres(): List<Category> suspend fun getGenres(): List<Category>
@GET("api/categories/countries") @GET("api/categories/countries")
suspend fun getCountries(): List<Category> suspend fun getCountries(): List<Category>
} }

View file

@ -1,113 +1,113 @@
package com.streamflow.tv.data.model package com.streamflow.tv.data.model
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
data class Movie( data class Movie(
val id: String = "", val id: String = "",
val title: String = "", val title: String = "",
@Json(name = "original_title") val originalTitle: String? = null, @Json(name = "original_title") val originalTitle: String? = null,
val slug: String = "", val slug: String = "",
val thumbnail: String = "", val thumbnail: String = "",
val backdrop: String? = null, val backdrop: String? = null,
val quality: String? = null, val quality: String? = null,
val year: Int? = null, val year: Int? = null,
val category: String = "", val category: String = "",
val time: String? = null, val time: String? = null,
val lang: String? = null, val lang: String? = null,
val director: String? = null, val director: String? = null,
val cast: List<String>? = null, val cast: List<String>? = null,
val provider: String? = null val provider: String? = null
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
data class MovieDetail( data class MovieDetail(
val id: String = "", val id: String = "",
val title: String = "", val title: String = "",
@Json(name = "original_title") val originalTitle: String? = null, @Json(name = "original_title") val originalTitle: String? = null,
val slug: String = "", val slug: String = "",
val thumbnail: String = "", val thumbnail: String = "",
val backdrop: String? = null, val backdrop: String? = null,
val quality: String? = null, val quality: String? = null,
val year: Int? = null, val year: Int? = null,
val category: String = "", val category: String = "",
val description: String = "", val description: String = "",
val rating: String? = null, val rating: String? = null,
val duration: Int? = null, val duration: Int? = null,
val genre: String? = null, val genre: String? = null,
val director: String? = null, val director: String? = null,
val country: String? = null, val country: String? = null,
val cast: List<String>? = null, val cast: List<String>? = null,
val provider: String? = null, val provider: String? = null,
val episodes: List<Episode>? = null val episodes: List<Episode>? = null
) { ) {
fun toMovie(): Movie = Movie( fun toMovie(): Movie = Movie(
id = id, id = id,
title = title, title = title,
originalTitle = originalTitle, originalTitle = originalTitle,
slug = slug, slug = slug,
thumbnail = thumbnail, thumbnail = thumbnail,
backdrop = backdrop, backdrop = backdrop,
quality = quality, quality = quality,
year = year, year = year,
category = category, category = category,
director = director, director = director,
cast = cast, cast = cast,
provider = provider provider = provider
) )
} }
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
data class Episode( data class Episode(
val number: Int = 0, val number: Int = 0,
val title: String = "", val title: String = "",
val url: String = "" val url: String = ""
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
data class VideoSource( data class VideoSource(
@Json(name = "stream_url") val streamUrl: String = "", @Json(name = "stream_url") val streamUrl: String = "",
val resolution: String = "", val resolution: String = "",
@Json(name = "format_id") val formatId: String = "" @Json(name = "format_id") val formatId: String = ""
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
data class Category( data class Category(
val name: String = "", val name: String = "",
val slug: String = "" val slug: String = ""
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
data class HomeResponse( data class HomeResponse(
val items: List<Movie> = emptyList(), val items: List<Movie> = emptyList(),
val totalPages: Int = 1, val totalPages: Int = 1,
val currentPage: Int = 1 val currentPage: Int = 1
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
data class ExtractRequest( data class ExtractRequest(
val url: String val url: String
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
data class MovieDetailResponse( data class MovieDetailResponse(
val id: String = "", val id: String = "",
val title: String = "", val title: String = "",
@Json(name = "original_title") val originalTitle: String? = null, @Json(name = "original_title") val originalTitle: String? = null,
val slug: String = "", val slug: String = "",
val thumbnail: String = "", val thumbnail: String = "",
val backdrop: String? = null, val backdrop: String? = null,
val quality: String? = null, val quality: String? = null,
val year: Int? = null, val year: Int? = null,
val category: String = "", val category: String = "",
val description: String = "", val description: String = "",
val rating: String? = null, val rating: String? = null,
val duration: Int? = null, val duration: Int? = null,
val genre: String? = null, val genre: String? = null,
val director: String? = null, val director: String? = null,
val country: String? = null, val country: String? = null,
val cast: List<String>? = null, val cast: List<String>? = null,
val episodes: List<Episode>? = null val episodes: List<Episode>? = null
) )

View file

@ -1,60 +1,60 @@
package com.streamflow.tv.data.repository package com.streamflow.tv.data.repository
import com.streamflow.tv.data.api.ApiClient import com.streamflow.tv.data.api.ApiClient
import com.streamflow.tv.data.model.* import com.streamflow.tv.data.model.*
class MovieRepository { class MovieRepository {
private val api get() = ApiClient.api private val api get() = ApiClient.api
suspend fun getHomeVideos(category: String? = null, page: Int = 1): HomeResponse { suspend fun getHomeVideos(category: String? = null, page: Int = 1): HomeResponse {
val list = api.getHomeVideos(category, page) val list = api.getHomeVideos(category, page)
android.util.Log.e("MovieRepo", "getHomeVideos($category): Received ${list.size} items") android.util.Log.e("MovieRepo", "getHomeVideos($category): Received ${list.size} items")
return HomeResponse(items = list, totalPages = 10, currentPage = page) return HomeResponse(items = list, totalPages = 10, currentPage = page)
} }
suspend fun searchVideos(query: String, page: Int = 1): HomeResponse { suspend fun searchVideos(query: String, page: Int = 1): HomeResponse {
val list = api.searchVideos(query, page) val list = api.searchVideos(query, page)
android.util.Log.e("MovieRepo", "searchVideos($query): Received ${list.size} items") android.util.Log.e("MovieRepo", "searchVideos($query): Received ${list.size} items")
return HomeResponse(items = list, totalPages = 1, currentPage = page) return HomeResponse(items = list, totalPages = 1, currentPage = page)
} }
suspend fun getMovieDetail(slug: String): MovieDetail { suspend fun getMovieDetail(slug: String): MovieDetail {
val response = api.getMovieDetail(slug) val response = api.getMovieDetail(slug)
// API returns a flat list of episodes // API returns a flat list of episodes
val episodes = response.episodes ?: emptyList() val episodes = response.episodes ?: emptyList()
return MovieDetail( return MovieDetail(
id = response.id, id = response.id,
title = response.title, title = response.title,
originalTitle = response.originalTitle, originalTitle = response.originalTitle,
slug = response.slug, slug = response.slug,
thumbnail = response.thumbnail, thumbnail = response.thumbnail,
backdrop = response.backdrop, backdrop = response.backdrop,
quality = response.quality, quality = response.quality,
year = response.year, year = response.year,
category = response.category, category = response.category,
description = response.description, description = response.description,
rating = response.rating, rating = response.rating,
duration = response.duration, duration = response.duration,
genre = response.genre, genre = response.genre,
director = response.director, director = response.director,
country = response.country, country = response.country,
cast = response.cast, cast = response.cast,
episodes = episodes episodes = episodes
) )
} }
suspend fun extractVideo(url: String): VideoSource { suspend fun extractVideo(url: String): VideoSource {
return api.extractVideo(ExtractRequest(url)) return api.extractVideo(ExtractRequest(url))
} }
suspend fun getGenres(): List<Category> { suspend fun getGenres(): List<Category> {
return api.getGenres() return api.getGenres()
} }
suspend fun getCountries(): List<Category> { suspend fun getCountries(): List<Category> {
return api.getCountries() return api.getCountries()
} }
} }

View file

@ -1,103 +1,103 @@
package com.streamflow.tv.data.repository package com.streamflow.tv.data.repository
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.* import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.Types import com.squareup.moshi.Types
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.streamflow.tv.data.model.Movie import com.streamflow.tv.data.model.Movie
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_data") private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "user_data")
class UserDataRepository(private val context: Context) { class UserDataRepository(private val context: Context) {
companion object { companion object {
private val MY_LIST_KEY = stringPreferencesKey("my_list") private val MY_LIST_KEY = stringPreferencesKey("my_list")
private val WATCH_HISTORY_KEY = stringPreferencesKey("watch_history") private val WATCH_HISTORY_KEY = stringPreferencesKey("watch_history")
private val THEME_KEY = stringPreferencesKey("theme") private val THEME_KEY = stringPreferencesKey("theme")
private val SERVER_URL_KEY = stringPreferencesKey("server_url") private val SERVER_URL_KEY = stringPreferencesKey("server_url")
private const val MAX_HISTORY = 50 private const val MAX_HISTORY = 50
} }
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val movieListType = Types.newParameterizedType(List::class.java, Movie::class.java) private val movieListType = Types.newParameterizedType(List::class.java, Movie::class.java)
private val movieListAdapter = moshi.adapter<List<Movie>>(movieListType) private val movieListAdapter = moshi.adapter<List<Movie>>(movieListType)
// --- My List --- // --- My List ---
val myList: Flow<List<Movie>> = context.dataStore.data.map { prefs -> val myList: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
val json = prefs[MY_LIST_KEY] ?: "[]" val json = prefs[MY_LIST_KEY] ?: "[]"
movieListAdapter.fromJson(json) ?: emptyList() movieListAdapter.fromJson(json) ?: emptyList()
} }
suspend fun addToMyList(movie: Movie) { suspend fun addToMyList(movie: Movie) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList() val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
if (current.none { it.slug == movie.slug }) { if (current.none { it.slug == movie.slug }) {
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current + movie) prefs[MY_LIST_KEY] = movieListAdapter.toJson(current + movie)
} }
} }
} }
suspend fun removeFromMyList(slug: String) { suspend fun removeFromMyList(slug: String) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList() val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
prefs[MY_LIST_KEY] = movieListAdapter.toJson(current.filter { it.slug != slug }) prefs[MY_LIST_KEY] = movieListAdapter.toJson(current.filter { it.slug != slug })
} }
} }
suspend fun isInMyList(slug: String): Boolean { suspend fun isInMyList(slug: String): Boolean {
var found = false var found = false
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList() val current = movieListAdapter.fromJson(prefs[MY_LIST_KEY] ?: "[]") ?: emptyList()
found = current.any { it.slug == slug } found = current.any { it.slug == slug }
} }
return found return found
} }
// --- Watch History --- // --- Watch History ---
val watchHistory: Flow<List<Movie>> = context.dataStore.data.map { prefs -> val watchHistory: Flow<List<Movie>> = context.dataStore.data.map { prefs ->
val json = prefs[WATCH_HISTORY_KEY] ?: "[]" val json = prefs[WATCH_HISTORY_KEY] ?: "[]"
movieListAdapter.fromJson(json) ?: emptyList() movieListAdapter.fromJson(json) ?: emptyList()
} }
suspend fun addToHistory(movie: Movie) { suspend fun addToHistory(movie: Movie) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
val current = movieListAdapter.fromJson(prefs[WATCH_HISTORY_KEY] ?: "[]")?.toMutableList() ?: mutableListOf() val current = movieListAdapter.fromJson(prefs[WATCH_HISTORY_KEY] ?: "[]")?.toMutableList() ?: mutableListOf()
current.removeAll { it.slug == movie.slug } current.removeAll { it.slug == movie.slug }
current.add(0, movie) // Most recent first current.add(0, movie) // Most recent first
val trimmed = current.take(MAX_HISTORY) val trimmed = current.take(MAX_HISTORY)
prefs[WATCH_HISTORY_KEY] = movieListAdapter.toJson(trimmed) prefs[WATCH_HISTORY_KEY] = movieListAdapter.toJson(trimmed)
} }
} }
// --- Theme --- // --- Theme ---
val theme: Flow<String> = context.dataStore.data.map { prefs -> val theme: Flow<String> = context.dataStore.data.map { prefs ->
prefs[THEME_KEY] ?: "default" prefs[THEME_KEY] ?: "default"
} }
suspend fun setTheme(theme: String) { suspend fun setTheme(theme: String) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
prefs[THEME_KEY] = theme prefs[THEME_KEY] = theme
} }
} }
// --- Server URL --- // --- Server URL ---
val serverUrl: Flow<String> = context.dataStore.data.map { prefs -> val serverUrl: Flow<String> = context.dataStore.data.map { prefs ->
prefs[SERVER_URL_KEY] ?: "https://nf.khoavo.myds.me" prefs[SERVER_URL_KEY] ?: "https://nf.khoavo.myds.me"
} }
suspend fun setServerUrl(url: String) { suspend fun setServerUrl(url: String) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
prefs[SERVER_URL_KEY] = url prefs[SERVER_URL_KEY] = url
} }
} }
} }

View file

@ -1,76 +1,76 @@
package com.streamflow.tv.ui.components package com.streamflow.tv.ui.components
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.foundation.lazy.grid.TvGridCells import androidx.tv.foundation.lazy.grid.TvGridCells
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.foundation.lazy.grid.items import androidx.tv.foundation.lazy.grid.items
import androidx.tv.material3.* import androidx.tv.material3.*
import com.streamflow.tv.data.model.Episode import com.streamflow.tv.data.model.Episode
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun EpisodeSelector( fun EpisodeSelector(
episodes: List<Episode>, episodes: List<Episode>,
currentEpisode: Int, currentEpisode: Int,
onEpisodeSelect: (Episode) -> Unit, onEpisodeSelect: (Episode) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
Column(modifier = modifier) { Column(modifier = modifier) {
Text( Text(
text = "Episodes", text = "Episodes",
style = StreamFlowTheme.typography.headlineMedium, style = StreamFlowTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 12.dp) modifier = Modifier.padding(bottom = 12.dp)
) )
android.util.Log.e("EpisodeSelector", "Rendering grid with ${episodes.size} episodes") android.util.Log.e("EpisodeSelector", "Rendering grid with ${episodes.size} episodes")
TvLazyVerticalGrid( TvLazyVerticalGrid(
columns = TvGridCells.Adaptive(minSize = 120.dp), columns = TvGridCells.Adaptive(minSize = 120.dp),
contentPadding = PaddingValues(4.dp), contentPadding = PaddingValues(4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(episodes) { episode -> items(episodes) { episode ->
val isActive = episode.number == currentEpisode val isActive = episode.number == currentEpisode
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
Surface( Surface(
onClick = { onEpisodeSelect(episode) }, onClick = { onEpisodeSelect(episode) },
modifier = Modifier modifier = Modifier
.onFocusChanged { isFocused = it.isFocused }, .onFocusChanged { isFocused = it.isFocused },
shape = ClickableSurfaceDefaults.shape( shape = ClickableSurfaceDefaults.shape(
shape = RoundedCornerShape(8.dp) shape = RoundedCornerShape(8.dp)
), ),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (isActive) colors.primary.copy(alpha = 0.2f) else colors.surfaceVariant, containerColor = if (isActive) colors.primary.copy(alpha = 0.2f) else colors.surfaceVariant,
focusedContainerColor = colors.primary.copy(alpha = 0.3f) focusedContainerColor = colors.primary.copy(alpha = 0.3f)
), ),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f) scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 12.dp, horizontal = 16.dp), .padding(vertical = 12.dp, horizontal = 16.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = if (episode.title.isNotBlank()) episode.title else "Ep ${episode.number}", text = if (episode.title.isNotBlank()) episode.title else "Ep ${episode.number}",
style = StreamFlowTheme.typography.labelLarge.copy( style = StreamFlowTheme.typography.labelLarge.copy(
color = if (isActive) colors.primary else Color.White color = if (isActive) colors.primary else Color.White
) )
) )
} }
} }
} }
} }
} }
} }

View file

@ -1,159 +1,159 @@
package com.streamflow.tv.ui.components package com.streamflow.tv.ui.components
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.* import androidx.tv.material3.*
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.streamflow.tv.data.api.ApiClient import com.streamflow.tv.data.api.ApiClient
import com.streamflow.tv.data.model.Movie import com.streamflow.tv.data.model.Movie
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun HeroBanner( fun HeroBanner(
movies: List<Movie>, movies: List<Movie>,
onPlayClick: (Movie) -> Unit, onPlayClick: (Movie) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (movies.isEmpty()) return if (movies.isEmpty()) return
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
var currentIndex by remember { mutableIntStateOf(0) } var currentIndex by remember { mutableIntStateOf(0) }
val currentMovie = movies[currentIndex] val currentMovie = movies[currentIndex]
LaunchedEffect(currentIndex) { LaunchedEffect(currentIndex) {
delay(6000) delay(6000)
currentIndex = (currentIndex + 1) % movies.size currentIndex = (currentIndex + 1) % movies.size
} }
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(480.dp) .height(480.dp)
) { ) {
AnimatedContent( AnimatedContent(
targetState = currentMovie, targetState = currentMovie,
transitionSpec = { fadeIn() togetherWith fadeOut() }, transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "hero-crossfade" label = "hero-crossfade"
) { movie -> ) { movie ->
AsyncImage( AsyncImage(
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280), model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
contentDescription = movie.title, contentDescription = movie.title,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background( .background(
Brush.horizontalGradient( Brush.horizontalGradient(
colors = listOf( colors = listOf(
colors.background.copy(alpha = 0.9f), colors.background.copy(alpha = 0.9f),
colors.background.copy(alpha = 0.5f), colors.background.copy(alpha = 0.5f),
Color.Transparent Color.Transparent
) )
) )
) )
) )
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(0.4f) .fillMaxHeight(0.4f)
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
colors = listOf(Color.Transparent, colors.background) colors = listOf(Color.Transparent, colors.background)
) )
) )
) )
Column( Column(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterStart) .align(Alignment.CenterStart)
.padding(start = 48.dp, end = 200.dp) .padding(start = 48.dp, end = 200.dp)
.fillMaxHeight(), .fillMaxHeight(),
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
currentMovie.quality?.let { quality -> currentMovie.quality?.let { quality ->
Box( Box(
modifier = Modifier modifier = Modifier
.background(colors.primary, RoundedCornerShape(4.dp)) .background(colors.primary, RoundedCornerShape(4.dp))
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
) { ) {
Text( Text(
text = quality, text = quality,
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White) style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
) )
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
Text( Text(
text = currentMovie.title, text = currentMovie.title,
style = StreamFlowTheme.typography.displayLarge, style = StreamFlowTheme.typography.displayLarge,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
currentMovie.year?.let { currentMovie.year?.let {
Text("$it", style = StreamFlowTheme.typography.bodyLarge) Text("$it", style = StreamFlowTheme.typography.bodyLarge)
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Surface( Surface(
onClick = { onPlayClick(currentMovie) }, onClick = { onPlayClick(currentMovie) },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = colors.primary, containerColor = colors.primary,
focusedContainerColor = colors.accent focusedContainerColor = colors.accent
), ),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f) scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
) { ) {
Text( Text(
text = "▶ Play Now", text = "▶ Play Now",
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White), style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp) modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
) )
} }
} }
Row( Row(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = 16.dp), .padding(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
movies.forEachIndexed { index, _ -> movies.forEachIndexed { index, _ ->
Box( Box(
modifier = Modifier modifier = Modifier
.size(if (index == currentIndex) 24.dp else 8.dp, 8.dp) .size(if (index == currentIndex) 24.dp else 8.dp, 8.dp)
.clip(CircleShape) .clip(CircleShape)
.background( .background(
if (index == currentIndex) colors.primary if (index == currentIndex) colors.primary
else Color.White.copy(alpha = 0.3f) else Color.White.copy(alpha = 0.3f)
) )
) )
} }
} }
} }
} }

View file

@ -1,116 +1,116 @@
package com.streamflow.tv.ui.components package com.streamflow.tv.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.* import androidx.tv.material3.*
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.streamflow.tv.data.api.ApiClient import com.streamflow.tv.data.api.ApiClient
import com.streamflow.tv.data.model.Movie import com.streamflow.tv.data.model.Movie
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun MovieCard( fun MovieCard(
movie: Movie, movie: Movie,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = modifier modifier = modifier
.width(200.dp) .width(200.dp)
.height(300.dp), .height(300.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = colors.surfaceVariant, containerColor = colors.surfaceVariant,
focusedContainerColor = colors.surfaceVariant focusedContainerColor = colors.surfaceVariant
), ),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f) scale = ClickableSurfaceDefaults.scale(focusedScale = 1.08f)
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
AsyncImage( AsyncImage(
model = ApiClient.imageProxyUrl(movie.thumbnail, 300), model = ApiClient.imageProxyUrl(movie.thumbnail, 300),
contentDescription = movie.title, contentDescription = movie.title,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
) )
movie.quality?.let { quality -> movie.quality?.let { quality ->
Box( Box(
modifier = Modifier modifier = Modifier
.padding(8.dp) .padding(8.dp)
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.background(colors.primary, RoundedCornerShape(4.dp)) .background(colors.primary, RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
) { ) {
Text( Text(
text = quality, text = quality,
style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White) style = StreamFlowTheme.typography.labelSmall.copy(color = Color.White)
) )
} }
} }
movie.provider?.let { provider -> movie.provider?.let { provider ->
Box( Box(
modifier = Modifier modifier = Modifier
.padding(8.dp) .padding(8.dp)
.align(Alignment.TopStart) .align(Alignment.TopStart)
.background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(4.dp)) .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
) { ) {
Text( Text(
text = provider, text = provider,
style = StreamFlowTheme.typography.labelSmall.copy( style = StreamFlowTheme.typography.labelSmall.copy(
color = Color.White.copy(alpha = 0.8f), color = Color.White.copy(alpha = 0.8f),
fontSize = androidx.compose.ui.unit.TextUnit.Unspecified // Default or small fontSize = androidx.compose.ui.unit.TextUnit.Unspecified // Default or small
), ),
maxLines = 1 maxLines = 1
) )
} }
} }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f)) colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.85f))
) )
) )
.padding(horizontal = 10.dp, vertical = 10.dp) .padding(horizontal = 10.dp, vertical = 10.dp)
) { ) {
Text( Text(
text = movie.title, text = movie.title,
style = StreamFlowTheme.typography.labelLarge, style = StreamFlowTheme.typography.labelLarge,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
movie.year?.let { year -> movie.year?.let { year ->
Text( Text(
text = year.toString(), text = year.toString(),
style = StreamFlowTheme.typography.labelSmall.copy( style = StreamFlowTheme.typography.labelSmall.copy(
color = Color.White.copy(alpha = 0.6f) color = Color.White.copy(alpha = 0.6f)
) )
) )
} }
} }
} }
} }
} }

View file

@ -1,43 +1,43 @@
package com.streamflow.tv.ui.components package com.streamflow.tv.ui.components
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.foundation.lazy.list.TvLazyRow import androidx.tv.foundation.lazy.list.TvLazyRow
import androidx.tv.foundation.lazy.list.items import androidx.tv.foundation.lazy.list.items
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.streamflow.tv.data.model.Movie import com.streamflow.tv.data.model.Movie
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun MovieRow( fun MovieRow(
title: String, title: String,
movies: List<Movie>, movies: List<Movie>,
onMovieClick: (Movie) -> Unit, onMovieClick: (Movie) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column(modifier = modifier.padding(vertical = 12.dp)) { Column(modifier = modifier.padding(vertical = 12.dp)) {
// Section title // Section title
Text( Text(
text = title, text = title,
style = StreamFlowTheme.typography.headlineMedium, style = StreamFlowTheme.typography.headlineMedium,
modifier = Modifier.padding(start = 48.dp, bottom = 12.dp) modifier = Modifier.padding(start = 48.dp, bottom = 12.dp)
) )
// Horizontal scrolling row of cards // Horizontal scrolling row of cards
TvLazyRow( TvLazyRow(
contentPadding = PaddingValues(horizontal = 48.dp), contentPadding = PaddingValues(horizontal = 48.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
items(movies) { movie -> items(movies) { movie ->
MovieCard( MovieCard(
movie = movie, movie = movie,
onClick = { onMovieClick(movie) } onClick = { onMovieClick(movie) }
) )
} }
} }
} }
} }

View file

@ -1,127 +1,127 @@
package com.streamflow.tv.ui.components package com.streamflow.tv.ui.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.* import androidx.tv.material3.*
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
data class NavItem( data class NavItem(
val id: String, val id: String,
val route: String, val route: String,
val label: String, val label: String,
val icon: ImageVector val icon: ImageVector
) )
val NAV_ITEMS = listOf( val NAV_ITEMS = listOf(
NavItem("home", "home", "Home", Icons.Default.Home), NavItem("home", "home", "Home", Icons.Default.Home),
NavItem("categories", "home/phim-le", "Categories", Icons.Default.Category), NavItem("categories", "home/phim-le", "Categories", Icons.Default.Category),
NavItem("search", "search", "Search", Icons.Default.Search), NavItem("search", "search", "Search", Icons.Default.Search),
NavItem("mylist", "mylist", "My List", Icons.Default.Favorite), NavItem("mylist", "mylist", "My List", Icons.Default.Favorite),
NavItem("settings", "settings", "Settings", Icons.Default.Settings) NavItem("settings", "settings", "Settings", Icons.Default.Settings)
) )
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun SideNavRail( fun SideNavRail(
selectedId: String, selectedId: String,
onNavigate: (NavItem) -> Unit, onNavigate: (NavItem) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { try {
focusRequester.requestFocus() focusRequester.requestFocus()
} catch (e: Exception) { } catch (e: Exception) {
// Ignore // Ignore
} }
} }
Column( Column(
modifier = modifier modifier = modifier
.fillMaxHeight() .fillMaxHeight()
.width(56.dp) .width(56.dp)
.background(colors.background.copy(alpha = 0.95f)) .background(colors.background.copy(alpha = 0.95f))
.padding(vertical = 16.dp), .padding(vertical = 16.dp),
verticalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
.clip(CircleShape) .clip(CircleShape)
.background(colors.primary), .background(colors.primary),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text("S", style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White)) Text("S", style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White))
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
NAV_ITEMS.forEach { item -> NAV_ITEMS.forEach { item ->
NavRailItem( NavRailItem(
item = item, item = item,
isSelected = selectedId == item.id, isSelected = selectedId == item.id,
onClick = { onNavigate(item) }, onClick = { onNavigate(item) },
accentColor = colors.primary, accentColor = colors.primary,
modifier = if (item.id == "home") Modifier.focusRequester(focusRequester) else Modifier modifier = if (item.id == "home") Modifier.focusRequester(focusRequester) else Modifier
) )
} }
} }
} }
} }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
private fun NavRailItem( private fun NavRailItem(
item: NavItem, item: NavItem,
isSelected: Boolean, isSelected: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
accentColor: Color, accentColor: Color,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
Surface( Surface(
onClick = onClick, onClick = onClick,
modifier = modifier modifier = modifier
.size(48.dp), .size(48.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(12.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (isSelected) accentColor.copy(alpha = 0.15f) else Color.Transparent, containerColor = if (isSelected) accentColor.copy(alpha = 0.15f) else Color.Transparent,
focusedContainerColor = accentColor.copy(alpha = 0.2f) focusedContainerColor = accentColor.copy(alpha = 0.2f)
), ),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f) scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f)
) { ) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = item.icon, imageVector = item.icon,
contentDescription = item.label, contentDescription = item.label,
tint = if (isSelected) accentColor else Color.White.copy(alpha = 0.6f), tint = if (isSelected) accentColor else Color.White.copy(alpha = 0.6f),
modifier = Modifier.size(22.dp) modifier = Modifier.size(22.dp)
) )
} }
} }
} }

View file

@ -1,98 +1,98 @@
package com.streamflow.tv.ui.navigation package com.streamflow.tv.ui.navigation
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.streamflow.tv.ui.screens.* import com.streamflow.tv.ui.screens.*
@Composable @Composable
fun AppNavigation( fun AppNavigation(
currentTheme: String, currentTheme: String,
onThemeChange: (String) -> Unit onThemeChange: (String) -> Unit
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") { NavHost(navController = navController, startDestination = "home") {
// Home (all categories) // Home (all categories)
composable("home") { composable("home") {
HomeScreen( HomeScreen(
onMovieClick = { slug -> navController.navigate("detail/$slug") } onMovieClick = { slug -> navController.navigate("detail/$slug") }
) )
} }
// Home filtered by category // Home filtered by category
composable( composable(
"home/{category}", "home/{category}",
arguments = listOf(navArgument("category") { type = NavType.StringType }) arguments = listOf(navArgument("category") { type = NavType.StringType })
) { backStackEntry -> ) { backStackEntry ->
val category = backStackEntry.arguments?.getString("category") val category = backStackEntry.arguments?.getString("category")
HomeScreen( HomeScreen(
onMovieClick = { slug -> navController.navigate("detail/$slug") }, onMovieClick = { slug -> navController.navigate("detail/$slug") },
category = category category = category
) )
} }
// Movie Detail // Movie Detail
composable( composable(
"detail/{slug}", "detail/{slug}",
arguments = listOf(navArgument("slug") { type = NavType.StringType }) arguments = listOf(navArgument("slug") { type = NavType.StringType })
) { backStackEntry -> ) { backStackEntry ->
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
DetailScreen( DetailScreen(
slug = slug, slug = slug,
onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") }, onPlayClick = { s, ep -> navController.navigate("player/$s/$ep") },
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() }
) )
} }
// Video Player // Video Player
composable( composable(
"player/{slug}/{episode}", "player/{slug}/{episode}",
arguments = listOf( arguments = listOf(
navArgument("slug") { type = NavType.StringType }, navArgument("slug") { type = NavType.StringType },
navArgument("episode") { type = NavType.IntType; defaultValue = 1 } navArgument("episode") { type = NavType.IntType; defaultValue = 1 }
) )
) { backStackEntry -> ) { backStackEntry ->
val slug = backStackEntry.arguments?.getString("slug") ?: return@composable val slug = backStackEntry.arguments?.getString("slug") ?: return@composable
val episode = backStackEntry.arguments?.getInt("episode") ?: 1 val episode = backStackEntry.arguments?.getInt("episode") ?: 1
PlayerScreen(slug = slug, episode = episode) PlayerScreen(slug = slug, episode = episode)
} }
// Search // Search
composable("search") { composable("search") {
SearchScreen( SearchScreen(
onMovieClick = { slug -> navController.navigate("detail/$slug") } onMovieClick = { slug -> navController.navigate("detail/$slug") }
) )
} }
// My List // My List
composable("mylist") { composable("mylist") {
MyListScreen( MyListScreen(
onMovieClick = { slug -> navController.navigate("detail/$slug") } onMovieClick = { slug -> navController.navigate("detail/$slug") }
) )
} }
// Settings // Settings
composable("settings") { composable("settings") {
SettingsScreen( SettingsScreen(
currentTheme = currentTheme, currentTheme = currentTheme,
onThemeChange = onThemeChange onThemeChange = onThemeChange
) )
} }
} }
// Expose navController for SideNavRail // Expose navController for SideNavRail
LaunchedEffect(navController) { LaunchedEffect(navController) {
// Store nav controller reference for side nav // Store nav controller reference for side nav
} }
// Provide nav controller via local // Provide nav controller via local
CompositionLocalProvider(LocalNavController provides navController) {} CompositionLocalProvider(LocalNavController provides navController) {}
} }
val LocalNavController = staticCompositionLocalOf<androidx.navigation.NavHostController> { val LocalNavController = staticCompositionLocalOf<androidx.navigation.NavHostController> {
error("NavController not provided") error("NavController not provided")
} }

View file

@ -1,200 +1,200 @@
package com.streamflow.tv.ui.screens package com.streamflow.tv.ui.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import android.util.Log import android.util.Log
import androidx.tv.material3.ClickableSurfaceDefaults import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface import androidx.tv.material3.Surface
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.streamflow.tv.data.api.ApiClient import com.streamflow.tv.data.api.ApiClient
import com.streamflow.tv.data.model.Episode import com.streamflow.tv.data.model.Episode
import com.streamflow.tv.ui.components.EpisodeSelector import com.streamflow.tv.ui.components.EpisodeSelector
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import com.streamflow.tv.viewmodel.DetailViewModel import com.streamflow.tv.viewmodel.DetailViewModel
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun DetailScreen( fun DetailScreen(
slug: String, slug: String,
onPlayClick: (String, Int) -> Unit, onPlayClick: (String, Int) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: DetailViewModel = viewModel() viewModel: DetailViewModel = viewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
LaunchedEffect(slug) { LaunchedEffect(slug) {
viewModel.loadMovie(slug) viewModel.loadMovie(slug)
} }
Log.d("DetailScreen", "Composing DetailScreen(slug=$slug, isLoading=${uiState.isLoading})") Log.d("DetailScreen", "Composing DetailScreen(slug=$slug, isLoading=${uiState.isLoading})")
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(colors.background), .background(colors.background),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (uiState.isLoading) { if (uiState.isLoading) {
CircularLoadingIndicator() CircularLoadingIndicator()
} else if (uiState.error != null) { } else if (uiState.error != null) {
ErrorState(message = uiState.error ?: "Unknown error", onRetry = { viewModel.loadMovie(slug) }) ErrorState(message = uiState.error ?: "Unknown error", onRetry = { viewModel.loadMovie(slug) })
} else { } else {
val movie = uiState.movie ?: return@Box val movie = uiState.movie ?: return@Box
Log.d("DetailScreen", "Rendering movie details: ${movie.title}") Log.d("DetailScreen", "Rendering movie details: ${movie.title}")
// Background Image // Background Image
AsyncImage( AsyncImage(
model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280), model = ApiClient.imageProxyUrl(movie.backdrop ?: movie.thumbnail, 1280),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
// Gradient Overlays // Gradient Overlays
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background( .background(
Brush.horizontalGradient( Brush.horizontalGradient(
colors = listOf( colors = listOf(
colors.background.copy(alpha = 0.95f), colors.background.copy(alpha = 0.95f),
colors.background.copy(alpha = 0.7f), colors.background.copy(alpha = 0.7f),
Color.Transparent Color.Transparent
) )
) )
) )
) )
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(0.3f) .fillMaxHeight(0.3f)
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
colors = listOf(Color.Transparent, colors.background) colors = listOf(Color.Transparent, colors.background)
) )
) )
) )
// Content // Content
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
LaunchedEffect(uiState.movie) { LaunchedEffect(uiState.movie) {
if (uiState.movie != null) { if (uiState.movie != null) {
focusRequester.requestFocus() focusRequester.requestFocus()
} }
} }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 48.dp, vertical = 32.dp), .padding(horizontal = 48.dp, vertical = 32.dp),
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text( Text(
text = movie.title, text = movie.title,
style = StreamFlowTheme.typography.displayLarge, style = StreamFlowTheme.typography.displayLarge,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text( Text(
text = movie.description, text = movie.description,
style = StreamFlowTheme.typography.bodyMedium, style = StreamFlowTheme.typography.bodyMedium,
maxLines = 3, maxLines = 3,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 600.dp) modifier = Modifier.widthIn(max = 600.dp)
) )
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
Surface( Surface(
onClick = { onPlayClick(movie.slug, 1) }, onClick = { onPlayClick(movie.slug, 1) },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = colors.primary, containerColor = colors.primary,
focusedContainerColor = colors.accent focusedContainerColor = colors.accent
), ),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f), scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
modifier = Modifier.focusRequester(focusRequester) modifier = Modifier.focusRequester(focusRequester)
) { ) {
Text( Text(
"▶ Play", "▶ Play",
style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White), style = StreamFlowTheme.typography.titleMedium.copy(color = Color.White),
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp) modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
) )
} }
if (!movie.episodes.isNullOrEmpty()) { if (!movie.episodes.isNullOrEmpty()) {
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
EpisodeSelector( EpisodeSelector(
episodes = movie.episodes, episodes = movie.episodes,
currentEpisode = 1, currentEpisode = 1,
onEpisodeSelect = { episode -> onPlayClick(movie.slug, episode.number) }, onEpisodeSelect = { episode -> onPlayClick(movie.slug, episode.number) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp) .height(200.dp)
) )
} }
} }
} }
} }
} }
@Composable @Composable
fun CircularLoadingIndicator() { fun CircularLoadingIndicator() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text( Text(
text = "Loading...", text = "Loading...",
style = StreamFlowTheme.typography.headlineMedium.copy(color = StreamFlowTheme.colors.primary) style = StreamFlowTheme.typography.headlineMedium.copy(color = StreamFlowTheme.colors.primary)
) )
} }
} }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun ErrorState(message: String, onRetry: () -> Unit) { fun ErrorState(message: String, onRetry: () -> Unit) {
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
Text( Text(
text = message, text = message,
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red), style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red),
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
) )
Surface( Surface(
onClick = onRetry, onClick = onRetry,
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = colors.surfaceVariant containerColor = colors.surfaceVariant
) )
) { ) {
Text( Text(
"Retry", "Retry",
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp) modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
) )
} }
} }
} }

View file

@ -1,112 +1,112 @@
package com.streamflow.tv.ui.screens package com.streamflow.tv.ui.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.foundation.lazy.list.TvLazyColumn import androidx.tv.foundation.lazy.list.TvLazyColumn
import androidx.tv.foundation.lazy.list.items import androidx.tv.foundation.lazy.list.items
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.streamflow.tv.ui.components.HeroBanner import com.streamflow.tv.ui.components.HeroBanner
import com.streamflow.tv.ui.components.MovieRow import com.streamflow.tv.ui.components.MovieRow
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import com.streamflow.tv.viewmodel.HomeViewModel import com.streamflow.tv.viewmodel.HomeViewModel
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun HomeScreen( fun HomeScreen(
onMovieClick: (String) -> Unit, onMovieClick: (String) -> Unit,
category: String? = null, category: String? = null,
userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null, userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null,
viewModel: HomeViewModel = viewModel() viewModel: HomeViewModel = viewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
LaunchedEffect(category) { LaunchedEffect(category) {
viewModel.loadHome(category, userDataRepository) viewModel.loadHome(category, userDataRepository)
} }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(colors.background) .background(colors.background)
) { ) {
if (uiState.isLoading) { if (uiState.isLoading) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "Loading...", text = "Loading...",
style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary) style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary)
) )
} }
} else if (uiState.error != null) { } else if (uiState.error != null) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = uiState.error ?: "Unknown error", text = uiState.error ?: "Unknown error",
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red) style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red)
) )
} }
} else { } else {
TvLazyColumn( TvLazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 24.dp) contentPadding = PaddingValues(bottom = 24.dp)
) { ) {
// Hero Banner // Hero Banner
if (uiState.heroMovies.isNotEmpty()) { if (uiState.heroMovies.isNotEmpty()) {
item { item {
HeroBanner( HeroBanner(
movies = uiState.heroMovies, movies = uiState.heroMovies,
onPlayClick = { movie -> onMovieClick(movie.slug) } onPlayClick = { movie -> onMovieClick(movie.slug) }
) )
} }
} }
// Continue Watching (Watch History) // Continue Watching (Watch History)
if (uiState.watchedMovies.isNotEmpty()) { if (uiState.watchedMovies.isNotEmpty()) {
item { item {
MovieRow( MovieRow(
title = "Continue Watching", title = "Continue Watching",
movies = uiState.watchedMovies, movies = uiState.watchedMovies,
onMovieClick = { movie -> onMovieClick(movie.slug) } onMovieClick = { movie -> onMovieClick(movie.slug) }
) )
} }
} }
// Recommended for You // Recommended for You
if (uiState.recommendedMovies.isNotEmpty()) { if (uiState.recommendedMovies.isNotEmpty()) {
item { item {
MovieRow( MovieRow(
title = "Recommended for You", title = "Recommended for You",
movies = uiState.recommendedMovies, movies = uiState.recommendedMovies,
onMovieClick = { movie -> onMovieClick(movie.slug) } onMovieClick = { movie -> onMovieClick(movie.slug) }
) )
} }
} }
// Category rows // Category rows
uiState.categoryMovies.forEach { (title, movies) -> uiState.categoryMovies.forEach { (title, movies) ->
if (movies.isNotEmpty()) { if (movies.isNotEmpty()) {
item { item {
MovieRow( MovieRow(
title = title, title = title,
movies = movies, movies = movies,
onMovieClick = { movie -> onMovieClick(movie.slug) } onMovieClick = { movie -> onMovieClick(movie.slug) }
) )
} }
} }
} }
} }
} }
} }
} }

View file

@ -1,104 +1,104 @@
package com.streamflow.tv.ui.screens package com.streamflow.tv.ui.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.foundation.lazy.grid.TvGridCells import androidx.tv.foundation.lazy.grid.TvGridCells
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.foundation.lazy.grid.items import androidx.tv.foundation.lazy.grid.items
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.streamflow.tv.ui.components.MovieCard import com.streamflow.tv.ui.components.MovieCard
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import com.streamflow.tv.viewmodel.MyListViewModel import com.streamflow.tv.viewmodel.MyListViewModel
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun MyListScreen( fun MyListScreen(
onMovieClick: (String) -> Unit, onMovieClick: (String) -> Unit,
viewModel: MyListViewModel = viewModel() viewModel: MyListViewModel = viewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(colors.background) .background(colors.background)
.padding(horizontal = 48.dp, vertical = 32.dp) .padding(horizontal = 48.dp, vertical = 32.dp)
) { ) {
Text( Text(
text = "My List", text = "My List",
style = StreamFlowTheme.typography.displayMedium, style = StreamFlowTheme.typography.displayMedium,
modifier = Modifier.padding(bottom = 24.dp) modifier = Modifier.padding(bottom = 24.dp)
) )
if (uiState.watchHistory.isEmpty() && uiState.savedMovies.isEmpty()) { if (uiState.watchHistory.isEmpty() && uiState.savedMovies.isEmpty()) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("❤️", style = StreamFlowTheme.typography.displayLarge) Text("❤️", style = StreamFlowTheme.typography.displayLarge)
Text( Text(
"Your list is empty.", "Your list is empty.",
style = StreamFlowTheme.typography.headlineMedium, style = StreamFlowTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 12.dp) modifier = Modifier.padding(top = 12.dp)
) )
Text( Text(
"Start watching or add movies to your list.", "Start watching or add movies to your list.",
style = StreamFlowTheme.typography.bodyLarge, style = StreamFlowTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 4.dp) modifier = Modifier.padding(top = 4.dp)
) )
} }
} }
} else { } else {
// Continue Watching // Continue Watching
if (uiState.watchHistory.isNotEmpty()) { if (uiState.watchHistory.isNotEmpty()) {
Text( Text(
text = "Continue Watching", text = "Continue Watching",
style = StreamFlowTheme.typography.headlineMedium, style = StreamFlowTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 12.dp) modifier = Modifier.padding(bottom = 12.dp)
) )
TvLazyVerticalGrid( TvLazyVerticalGrid(
columns = TvGridCells.Adaptive(180.dp), columns = TvGridCells.Adaptive(180.dp),
contentPadding = PaddingValues(4.dp), contentPadding = PaddingValues(4.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.heightIn(max = 320.dp) modifier = Modifier.heightIn(max = 320.dp)
) { ) {
items(uiState.watchHistory, key = { "h_${it.slug}" }) { movie -> items(uiState.watchHistory, key = { "h_${it.slug}" }) { movie ->
MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) }) MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) })
} }
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
} }
// Saved // Saved
if (uiState.savedMovies.isNotEmpty()) { if (uiState.savedMovies.isNotEmpty()) {
Text( Text(
text = "Saved Movies", text = "Saved Movies",
style = StreamFlowTheme.typography.headlineMedium, style = StreamFlowTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 12.dp) modifier = Modifier.padding(bottom = 12.dp)
) )
TvLazyVerticalGrid( TvLazyVerticalGrid(
columns = TvGridCells.Adaptive(180.dp), columns = TvGridCells.Adaptive(180.dp),
contentPadding = PaddingValues(4.dp), contentPadding = PaddingValues(4.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
items(uiState.savedMovies, key = { "s_${it.slug}" }) { movie -> items(uiState.savedMovies, key = { "s_${it.slug}" }) { movie ->
MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) }) MovieCard(movie = movie, onClick = { onMovieClick(movie.slug) })
} }
} }
} }
} }
} }
} }

View file

@ -1,249 +1,249 @@
package com.streamflow.tv.ui.screens package com.streamflow.tv.ui.screens
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.* import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultDataSource
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import com.streamflow.tv.viewmodel.PlayerViewModel import com.streamflow.tv.viewmodel.PlayerViewModel
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@kotlin.OptIn(ExperimentalTvMaterial3Api::class) @kotlin.OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun PlayerScreen( fun PlayerScreen(
slug: String, slug: String,
episode: Int = 1, episode: Int = 1,
userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null, userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null,
viewModel: PlayerViewModel = viewModel() viewModel: PlayerViewModel = viewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
var playerView by remember { mutableStateOf<PlayerView?>(null) } var playerView by remember { mutableStateOf<PlayerView?>(null) }
LaunchedEffect(slug, episode) { LaunchedEffect(slug, episode) {
viewModel.loadPlayer(slug, episode) viewModel.loadPlayer(slug, episode)
} }
LaunchedEffect(uiState.movie) { LaunchedEffect(uiState.movie) {
if (uiState.movie != null && userDataRepository != null) { if (uiState.movie != null && userDataRepository != null) {
viewModel.saveToHistory(userDataRepository) viewModel.saveToHistory(userDataRepository)
} }
} }
// ExoPlayer instance // ExoPlayer instance
val exoPlayer = remember { val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply { ExoPlayer.Builder(context).build().apply {
playWhenReady = true playWhenReady = true
} }
} }
// Wrap ExoPlayer to intercept next/previous UI clicks // Wrap ExoPlayer to intercept next/previous UI clicks
val forwardingPlayer = remember(exoPlayer, uiState.movie, uiState.currentEpisode) { val forwardingPlayer = remember(exoPlayer, uiState.movie, uiState.currentEpisode) {
object : androidx.media3.common.ForwardingPlayer(exoPlayer) { object : androidx.media3.common.ForwardingPlayer(exoPlayer) {
override fun getAvailableCommands(): androidx.media3.common.Player.Commands { override fun getAvailableCommands(): androidx.media3.common.Player.Commands {
return super.getAvailableCommands().buildUpon() return super.getAvailableCommands().buildUpon()
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT) .add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT)
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS) .add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS)
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) .add(androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) .add(androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
.build() .build()
} }
override fun hasNextMediaItem(): Boolean { override fun hasNextMediaItem(): Boolean {
val eps = uiState.movie?.episodes ?: return false val eps = uiState.movie?.episodes ?: return false
if (eps.isEmpty()) return false if (eps.isEmpty()) return false
val maxEp = eps.maxOf { it.number } val maxEp = eps.maxOf { it.number }
return uiState.currentEpisode < maxEp return uiState.currentEpisode < maxEp
} }
override fun hasPreviousMediaItem(): Boolean { override fun hasPreviousMediaItem(): Boolean {
val eps = uiState.movie?.episodes ?: return false val eps = uiState.movie?.episodes ?: return false
if (eps.isEmpty()) return false if (eps.isEmpty()) return false
val minEp = eps.minOf { it.number } val minEp = eps.minOf { it.number }
return uiState.currentEpisode > minEp return uiState.currentEpisode > minEp
} }
override fun seekToNextMediaItem() { override fun seekToNextMediaItem() {
if (hasNextMediaItem()) { if (hasNextMediaItem()) {
viewModel.changeEpisode(uiState.currentEpisode + 1) viewModel.changeEpisode(uiState.currentEpisode + 1)
} }
} }
override fun seekToNext() { override fun seekToNext() {
seekToNextMediaItem() seekToNextMediaItem()
} }
override fun seekToPreviousMediaItem() { override fun seekToPreviousMediaItem() {
if (hasPreviousMediaItem()) { if (hasPreviousMediaItem()) {
viewModel.changeEpisode(uiState.currentEpisode - 1) viewModel.changeEpisode(uiState.currentEpisode - 1)
} }
} }
override fun seekToPrevious() { override fun seekToPrevious() {
seekToPreviousMediaItem() seekToPreviousMediaItem()
} }
} }
} }
// Update player when source changes // Update player when source changes
LaunchedEffect(uiState.source) { LaunchedEffect(uiState.source) {
uiState.source?.let { source -> uiState.source?.let { source ->
val dataSourceFactory = DefaultDataSource.Factory(context) val dataSourceFactory = DefaultDataSource.Factory(context)
val mediaItem = MediaItem.fromUri(source.streamUrl) val mediaItem = MediaItem.fromUri(source.streamUrl)
android.util.Log.e("StreamFlowPlayer", "Setting media source: ${source.streamUrl}") android.util.Log.e("StreamFlowPlayer", "Setting media source: ${source.streamUrl}")
exoPlayer.addListener(object : androidx.media3.common.Player.Listener { exoPlayer.addListener(object : androidx.media3.common.Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) { override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
android.util.Log.e("StreamFlowPlayer", "Player Error: ${error.message}", error) android.util.Log.e("StreamFlowPlayer", "Player Error: ${error.message}", error)
} }
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
android.util.Log.e("StreamFlowPlayer", "Playback State: $playbackState") android.util.Log.e("StreamFlowPlayer", "Playback State: $playbackState")
} }
}) })
if (source.streamUrl.contains(".m3u8")) { if (source.streamUrl.contains(".m3u8")) {
val hlsSource = HlsMediaSource.Factory(dataSourceFactory) val hlsSource = HlsMediaSource.Factory(dataSourceFactory)
.createMediaSource(mediaItem) .createMediaSource(mediaItem)
exoPlayer.setMediaSource(hlsSource) exoPlayer.setMediaSource(hlsSource)
} else { } else {
exoPlayer.setMediaItem(mediaItem) exoPlayer.setMediaItem(mediaItem)
} }
exoPlayer.prepare() exoPlayer.prepare()
} }
} }
// Cleanup // Cleanup
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
exoPlayer.release() exoPlayer.release()
} }
} }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
.focusRequester(focusRequester) .focusRequester(focusRequester)
.focusable() .focusable()
.onPreviewKeyEvent { keyEvent -> .onPreviewKeyEvent { keyEvent ->
if (keyEvent.type == KeyEventType.KeyDown) { if (keyEvent.type == KeyEventType.KeyDown) {
when (keyEvent.nativeKeyEvent.keyCode) { when (keyEvent.nativeKeyEvent.keyCode) {
android.view.KeyEvent.KEYCODE_DPAD_CENTER, android.view.KeyEvent.KEYCODE_DPAD_CENTER,
android.view.KeyEvent.KEYCODE_ENTER -> { android.view.KeyEvent.KEYCODE_ENTER -> {
// Toggle controls visibility // Toggle controls visibility
if (playerView?.isControllerFullyVisible == true) { if (playerView?.isControllerFullyVisible == true) {
playerView?.hideController() playerView?.hideController()
} else { } else {
playerView?.showController() playerView?.showController()
} }
true true
} }
android.view.KeyEvent.KEYCODE_DPAD_LEFT -> { android.view.KeyEvent.KEYCODE_DPAD_LEFT -> {
// Seek backward 10s // Seek backward 10s
playerView?.showController() playerView?.showController()
exoPlayer.seekTo(maxOf(0, exoPlayer.currentPosition - 10000)) exoPlayer.seekTo(maxOf(0, exoPlayer.currentPosition - 10000))
true true
} }
android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> { android.view.KeyEvent.KEYCODE_DPAD_RIGHT -> {
// Seek forward 10s // Seek forward 10s
playerView?.showController() playerView?.showController()
exoPlayer.seekTo(minOf(exoPlayer.duration, exoPlayer.currentPosition + 10000)) exoPlayer.seekTo(minOf(exoPlayer.duration, exoPlayer.currentPosition + 10000))
true true
} }
android.view.KeyEvent.KEYCODE_DPAD_UP, android.view.KeyEvent.KEYCODE_DPAD_UP,
android.view.KeyEvent.KEYCODE_DPAD_DOWN -> { android.view.KeyEvent.KEYCODE_DPAD_DOWN -> {
playerView?.showController() playerView?.showController()
true true
} }
android.view.KeyEvent.KEYCODE_MEDIA_NEXT -> { android.view.KeyEvent.KEYCODE_MEDIA_NEXT -> {
if (forwardingPlayer.hasNextMediaItem()) { if (forwardingPlayer.hasNextMediaItem()) {
forwardingPlayer.seekToNextMediaItem() forwardingPlayer.seekToNextMediaItem()
} }
true true
} }
android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
if (forwardingPlayer.hasPreviousMediaItem()) { if (forwardingPlayer.hasPreviousMediaItem()) {
forwardingPlayer.seekToPreviousMediaItem() forwardingPlayer.seekToPreviousMediaItem()
} }
true true
} }
else -> false else -> false
} }
} else false } else false
} }
) { ) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.requestFocus() focusRequester.requestFocus()
} }
if (uiState.isLoading || uiState.source == null) { if (uiState.isLoading || uiState.source == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
"Loading stream...", "Loading stream...",
style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary) style = StreamFlowTheme.typography.headlineMedium.copy(color = colors.primary)
) )
uiState.movie?.let { movie -> uiState.movie?.let { movie ->
Text( Text(
movie.title, movie.title,
style = StreamFlowTheme.typography.bodyLarge, style = StreamFlowTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 8.dp) modifier = Modifier.padding(top = 8.dp)
) )
} }
} }
} }
} else { } else {
// ExoPlayer View // ExoPlayer View
android.util.Log.e("StreamFlowPlayer", "Drawing AndroidView for Player") android.util.Log.e("StreamFlowPlayer", "Drawing AndroidView for Player")
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
android.util.Log.e("StreamFlowPlayer", "Creating PlayerView factory") android.util.Log.e("StreamFlowPlayer", "Creating PlayerView factory")
PlayerView(ctx).apply { PlayerView(ctx).apply {
player = forwardingPlayer player = forwardingPlayer
useController = true useController = true
setShowNextButton(true) setShowNextButton(true)
setShowPreviousButton(true) setShowPreviousButton(true)
controllerAutoShow = true controllerAutoShow = true
keepScreenOn = true // Prevent screen sleep during playback keepScreenOn = true // Prevent screen sleep during playback
layoutParams = FrameLayout.LayoutParams( layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT ViewGroup.LayoutParams.MATCH_PARENT
) )
playerView = this playerView = this
} }
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
// Error overlay // Error overlay
uiState.error?.let { error -> uiState.error?.let { error ->
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text( Text(
error, error,
style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red) style = StreamFlowTheme.typography.bodyLarge.copy(color = Color.Red)
) )
} }
} }
} }
} }

View file

@ -1,124 +1,124 @@
package com.streamflow.tv.ui.screens package com.streamflow.tv.ui.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.foundation.lazy.grid.TvGridCells import androidx.tv.foundation.lazy.grid.TvGridCells
import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.foundation.lazy.grid.items import androidx.tv.foundation.lazy.grid.items
import androidx.tv.material3.* import androidx.tv.material3.*
import com.streamflow.tv.ui.components.MovieCard import com.streamflow.tv.ui.components.MovieCard
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import com.streamflow.tv.viewmodel.SearchViewModel import com.streamflow.tv.viewmodel.SearchViewModel
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun SearchScreen( fun SearchScreen(
onMovieClick: (String) -> Unit, onMovieClick: (String) -> Unit,
viewModel: SearchViewModel = viewModel() viewModel: SearchViewModel = viewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
var textValue by remember { mutableStateOf(TextFieldValue("")) } var textValue by remember { mutableStateOf(TextFieldValue("")) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(colors.background) .background(colors.background)
.padding(horizontal = 48.dp, vertical = 32.dp) .padding(horizontal = 48.dp, vertical = 32.dp)
) { ) {
// Search bar // Search bar
Text( Text(
text = "Search", text = "Search",
style = StreamFlowTheme.typography.displayMedium, style = StreamFlowTheme.typography.displayMedium,
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
) )
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(colors.surfaceVariant, RoundedCornerShape(12.dp)) .background(colors.surfaceVariant, RoundedCornerShape(12.dp))
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp)
) { ) {
Text("🔍 ", style = StreamFlowTheme.typography.titleMedium) Text("🔍 ", style = StreamFlowTheme.typography.titleMedium)
BasicTextField( BasicTextField(
value = textValue, value = textValue,
onValueChange = { onValueChange = {
textValue = it textValue = it
if (it.text.length >= 2) { if (it.text.length >= 2) {
viewModel.search(it.text) viewModel.search(it.text)
} }
}, },
textStyle = StreamFlowTheme.typography.titleMedium, textStyle = StreamFlowTheme.typography.titleMedium,
cursorBrush = SolidColor(colors.primary), cursorBrush = SolidColor(colors.primary),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
Box { Box {
if (textValue.text.isEmpty()) { if (textValue.text.isEmpty()) {
Text( Text(
"Type to search...", "Type to search...",
style = StreamFlowTheme.typography.titleMedium.copy( style = StreamFlowTheme.typography.titleMedium.copy(
color = Color.White.copy(alpha = 0.3f) color = Color.White.copy(alpha = 0.3f)
) )
) )
} }
innerTextField() innerTextField()
} }
} }
) )
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
// Results // Results
when { when {
uiState.isLoading -> { uiState.isLoading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Searching...", style = StreamFlowTheme.typography.bodyLarge.copy(color = colors.primary)) Text("Searching...", style = StreamFlowTheme.typography.bodyLarge.copy(color = colors.primary))
} }
} }
uiState.results.isNotEmpty() -> { uiState.results.isNotEmpty() -> {
TvLazyVerticalGrid( TvLazyVerticalGrid(
columns = TvGridCells.Adaptive(180.dp), columns = TvGridCells.Adaptive(180.dp),
contentPadding = PaddingValues(4.dp), contentPadding = PaddingValues(4.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
items(uiState.results, key = { it.slug }) { movie -> items(uiState.results, key = { it.slug }) { movie ->
MovieCard( MovieCard(
movie = movie, movie = movie,
onClick = { onMovieClick(movie.slug) } onClick = { onMovieClick(movie.slug) }
) )
} }
} }
} }
uiState.hasSearched -> { uiState.hasSearched -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No results found", style = StreamFlowTheme.typography.bodyLarge) Text("No results found", style = StreamFlowTheme.typography.bodyLarge)
} }
} }
else -> { else -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("🎬", style = StreamFlowTheme.typography.displayLarge) Text("🎬", style = StreamFlowTheme.typography.displayLarge)
Text( Text(
"Search for movies and shows", "Search for movies and shows",
style = StreamFlowTheme.typography.bodyLarge, style = StreamFlowTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 12.dp) modifier = Modifier.padding(top = 12.dp)
) )
} }
} }
} }
} }
} }
} }

View file

@ -1,171 +1,171 @@
package com.streamflow.tv.ui.screens package com.streamflow.tv.ui.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.* import androidx.tv.material3.*
import com.streamflow.tv.data.api.ApiClient import com.streamflow.tv.data.api.ApiClient
import com.streamflow.tv.data.repository.UserDataRepository import com.streamflow.tv.data.repository.UserDataRepository
import com.streamflow.tv.ui.theme.StreamFlowTheme import com.streamflow.tv.ui.theme.StreamFlowTheme
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
currentTheme: String, currentTheme: String,
onThemeChange: (String) -> Unit onThemeChange: (String) -> Unit
) { ) {
val colors = StreamFlowTheme.colors val colors = StreamFlowTheme.colors
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val userRepo = remember { UserDataRepository(context) } val userRepo = remember { UserDataRepository(context) }
var serverUrl by remember { mutableStateOf(TextFieldValue(ApiClient.baseUrl.removeSuffix("/"))) } var serverUrl by remember { mutableStateOf(TextFieldValue(ApiClient.baseUrl.removeSuffix("/"))) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val savedUrl = userRepo.serverUrl.first() val savedUrl = userRepo.serverUrl.first()
serverUrl = TextFieldValue(savedUrl) serverUrl = TextFieldValue(savedUrl)
} }
val themes = listOf( val themes = listOf(
Triple("default", "StreamFlow", Color(0xFF06B6D4)), Triple("default", "StreamFlow", Color(0xFF06B6D4)),
Triple("netflix", "Netflix", Color(0xFFE50914)), Triple("netflix", "Netflix", Color(0xFFE50914)),
Triple("apple", "Apple TV+", Color(0xFFFFFFFF)) Triple("apple", "Apple TV+", Color(0xFFFFFFFF))
) )
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(colors.background) .background(colors.background)
.padding(horizontal = 48.dp, vertical = 32.dp) .padding(horizontal = 48.dp, vertical = 32.dp)
) { ) {
Text( Text(
text = "Settings", text = "Settings",
style = StreamFlowTheme.typography.displayMedium, style = StreamFlowTheme.typography.displayMedium,
modifier = Modifier.padding(bottom = 32.dp) modifier = Modifier.padding(bottom = 32.dp)
) )
Text( Text(
text = "CHOOSE THEME", text = "CHOOSE THEME",
style = StreamFlowTheme.typography.labelSmall.copy( style = StreamFlowTheme.typography.labelSmall.copy(
color = Color.White.copy(alpha = 0.5f) color = Color.White.copy(alpha = 0.5f)
), ),
modifier = Modifier.padding(bottom = 12.dp) modifier = Modifier.padding(bottom = 12.dp)
) )
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
themes.forEach { (id, name, color) -> themes.forEach { (id, name, color) ->
val isSelected = currentTheme == id val isSelected = currentTheme == id
Surface( Surface(
onClick = { onThemeChange(id) }, onClick = { onThemeChange(id) },
modifier = Modifier.width(200.dp), modifier = Modifier.width(200.dp),
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(16.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = if (isSelected) Color.White.copy(alpha = 0.1f) else colors.surfaceVariant, containerColor = if (isSelected) Color.White.copy(alpha = 0.1f) else colors.surfaceVariant,
focusedContainerColor = Color.White.copy(alpha = 0.15f) focusedContainerColor = Color.White.copy(alpha = 0.15f)
), ),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f) scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
) { ) {
Column( Column(
modifier = Modifier.padding(20.dp), modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.background(Color.Black, RoundedCornerShape(12.dp)), .background(Color.Black, RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = name.first().toString(), text = name.first().toString(),
style = StreamFlowTheme.typography.headlineLarge.copy(color = color) style = StreamFlowTheme.typography.headlineLarge.copy(color = color)
) )
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
text = name, text = name,
style = StreamFlowTheme.typography.titleMedium style = StreamFlowTheme.typography.titleMedium
) )
if (isSelected) { if (isSelected) {
Text( Text(
text = "✓ Active", text = "✓ Active",
style = StreamFlowTheme.typography.labelSmall.copy( style = StreamFlowTheme.typography.labelSmall.copy(
color = Color(0xFF22C55E) color = Color(0xFF22C55E)
), ),
modifier = Modifier.padding(top = 4.dp) modifier = Modifier.padding(top = 4.dp)
) )
} }
} }
} }
} }
} }
Spacer(Modifier.height(40.dp)) Spacer(Modifier.height(40.dp))
Text( Text(
text = "SERVER URL", text = "SERVER URL",
style = StreamFlowTheme.typography.labelSmall.copy( style = StreamFlowTheme.typography.labelSmall.copy(
color = Color.White.copy(alpha = 0.5f) color = Color.White.copy(alpha = 0.5f)
), ),
modifier = Modifier.padding(bottom = 12.dp) modifier = Modifier.padding(bottom = 12.dp)
) )
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
BasicTextField( BasicTextField(
value = serverUrl, value = serverUrl,
onValueChange = { serverUrl = it }, onValueChange = { serverUrl = it },
textStyle = StreamFlowTheme.typography.titleMedium, textStyle = StreamFlowTheme.typography.titleMedium,
cursorBrush = SolidColor(colors.primary), cursorBrush = SolidColor(colors.primary),
modifier = Modifier modifier = Modifier
.width(400.dp) .width(400.dp)
.background(colors.surfaceVariant, RoundedCornerShape(12.dp)) .background(colors.surfaceVariant, RoundedCornerShape(12.dp))
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp)
) )
Surface( Surface(
onClick = { onClick = {
val url = serverUrl.text.trim() val url = serverUrl.text.trim()
ApiClient.baseUrl = url ApiClient.baseUrl = url
scope.launch { userRepo.setServerUrl(url) } scope.launch { userRepo.setServerUrl(url) }
}, },
shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)), shape = ClickableSurfaceDefaults.shape(shape = RoundedCornerShape(8.dp)),
colors = ClickableSurfaceDefaults.colors( colors = ClickableSurfaceDefaults.colors(
containerColor = colors.primary, containerColor = colors.primary,
focusedContainerColor = colors.accent focusedContainerColor = colors.accent
), ),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f) scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
) { ) {
Text( Text(
"Save", "Save",
style = StreamFlowTheme.typography.labelLarge.copy(color = Color.White), style = StreamFlowTheme.typography.labelLarge.copy(color = Color.White),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp) modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
) )
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text( Text(
text = "Enter the IP address and port of your StreamFlow backend server.", text = "Enter the IP address and port of your StreamFlow backend server.",
style = StreamFlowTheme.typography.bodyMedium, style = StreamFlowTheme.typography.bodyMedium,
modifier = Modifier.widthIn(max = 500.dp) modifier = Modifier.widthIn(max = 500.dp)
) )
} }
} }

View file

@ -1,28 +1,28 @@
package com.streamflow.tv.ui.theme package com.streamflow.tv.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
// StreamFlow Default Theme (Cyan/Blue) // StreamFlow Default Theme (Cyan/Blue)
val StreamFlowPrimary = Color(0xFF06B6D4) val StreamFlowPrimary = Color(0xFF06B6D4)
val StreamFlowSecondary = Color(0xFF3B82F6) val StreamFlowSecondary = Color(0xFF3B82F6)
val StreamFlowAccent = Color(0xFF22D3EE) val StreamFlowAccent = Color(0xFF22D3EE)
// Netflix Theme (Red) // Netflix Theme (Red)
val NetflixPrimary = Color(0xFFE50914) val NetflixPrimary = Color(0xFFE50914)
val NetflixSecondary = Color(0xFFB81D24) val NetflixSecondary = Color(0xFFB81D24)
val NetflixAccent = Color(0xFFFF3D3D) val NetflixAccent = Color(0xFFFF3D3D)
// Apple TV+ Theme (White/Silver) // Apple TV+ Theme (White/Silver)
val ApplePrimary = Color(0xFFFFFFFF) val ApplePrimary = Color(0xFFFFFFFF)
val AppleSecondary = Color(0xFFA1A1AA) val AppleSecondary = Color(0xFFA1A1AA)
val AppleAccent = Color(0xFFD4D4D8) val AppleAccent = Color(0xFFD4D4D8)
// Common // Common
val DarkBackground = Color(0xFF141414) val DarkBackground = Color(0xFF141414)
val DarkSurface = Color(0xFF1A1A1A) val DarkSurface = Color(0xFF1A1A1A)
val DarkSurfaceVariant = Color(0xFF262626) val DarkSurfaceVariant = Color(0xFF262626)
val TextPrimary = Color(0xFFFFFFFF) val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFF9CA3AF) val TextSecondary = Color(0xFF9CA3AF)
val TextMuted = Color(0xFF6B7280) val TextMuted = Color(0xFF6B7280)
val CardBackground = Color(0xFF1E1E1E) val CardBackground = Color(0xFF1E1E1E)
val DividerColor = Color(0x1AFFFFFF) val DividerColor = Color(0x1AFFFFFF)

View file

@ -1,122 +1,122 @@
package com.streamflow.tv.ui.theme package com.streamflow.tv.ui.theme
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.tv.material3.* import androidx.tv.material3.*
data class StreamFlowColors( data class StreamFlowColors(
val primary: Color, val primary: Color,
val secondary: Color, val secondary: Color,
val accent: Color, val accent: Color,
val background: Color = DarkBackground, val background: Color = DarkBackground,
val surface: Color = DarkSurface, val surface: Color = DarkSurface,
val surfaceVariant: Color = DarkSurfaceVariant, val surfaceVariant: Color = DarkSurfaceVariant,
val textPrimary: Color = TextPrimary, val textPrimary: Color = TextPrimary,
val textSecondary: Color = TextSecondary, val textSecondary: Color = TextSecondary,
val card: Color = CardBackground, val card: Color = CardBackground,
val divider: Color = DividerColor val divider: Color = DividerColor
) )
val LocalStreamFlowColors = staticCompositionLocalOf { val LocalStreamFlowColors = staticCompositionLocalOf {
StreamFlowColors( StreamFlowColors(
primary = StreamFlowPrimary, primary = StreamFlowPrimary,
secondary = StreamFlowSecondary, secondary = StreamFlowSecondary,
accent = StreamFlowAccent accent = StreamFlowAccent
) )
} }
object StreamFlowTheme { object StreamFlowTheme {
val colors: StreamFlowColors val colors: StreamFlowColors
@Composable @Composable
@ReadOnlyComposable @ReadOnlyComposable
get() = LocalStreamFlowColors.current get() = LocalStreamFlowColors.current
val typography = AppTypography val typography = AppTypography
} }
fun streamFlowColors(themeName: String): StreamFlowColors { fun streamFlowColors(themeName: String): StreamFlowColors {
return when (themeName) { return when (themeName) {
"netflix" -> StreamFlowColors( "netflix" -> StreamFlowColors(
primary = NetflixPrimary, primary = NetflixPrimary,
secondary = NetflixSecondary, secondary = NetflixSecondary,
accent = NetflixAccent accent = NetflixAccent
) )
"apple" -> StreamFlowColors( "apple" -> StreamFlowColors(
primary = ApplePrimary, primary = ApplePrimary,
secondary = AppleSecondary, secondary = AppleSecondary,
accent = AppleAccent accent = AppleAccent
) )
else -> StreamFlowColors( else -> StreamFlowColors(
primary = StreamFlowPrimary, primary = StreamFlowPrimary,
secondary = StreamFlowSecondary, secondary = StreamFlowSecondary,
accent = StreamFlowAccent accent = StreamFlowAccent
) )
} }
} }
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
fun StreamFlowTvTheme( fun StreamFlowTvTheme(
themeName: String = "default", themeName: String = "default",
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colors = streamFlowColors(themeName) val colors = streamFlowColors(themeName)
val colorScheme = ColorScheme( val colorScheme = ColorScheme(
primary = colors.primary, primary = colors.primary,
onPrimary = Color.White, onPrimary = Color.White,
primaryContainer = colors.primary.copy(alpha = 0.3f), primaryContainer = colors.primary.copy(alpha = 0.3f),
onPrimaryContainer = Color.White, onPrimaryContainer = Color.White,
secondary = colors.secondary, secondary = colors.secondary,
onSecondary = Color.White, onSecondary = Color.White,
secondaryContainer = colors.secondary.copy(alpha = 0.3f), secondaryContainer = colors.secondary.copy(alpha = 0.3f),
onSecondaryContainer = Color.White, onSecondaryContainer = Color.White,
tertiary = colors.accent, tertiary = colors.accent,
onTertiary = Color.Black, onTertiary = Color.Black,
tertiaryContainer = colors.accent.copy(alpha = 0.3f), tertiaryContainer = colors.accent.copy(alpha = 0.3f),
onTertiaryContainer = Color.White, onTertiaryContainer = Color.White,
background = colors.background, background = colors.background,
onBackground = Color.White, onBackground = Color.White,
surface = colors.surface, surface = colors.surface,
onSurface = Color.White, onSurface = Color.White,
surfaceVariant = colors.surfaceVariant, surfaceVariant = colors.surfaceVariant,
onSurfaceVariant = Color.White, onSurfaceVariant = Color.White,
error = Color.Red, error = Color.Red,
onError = Color.White, onError = Color.White,
errorContainer = Color.Red.copy(alpha = 0.1f), errorContainer = Color.Red.copy(alpha = 0.1f),
onErrorContainer = Color.Red, onErrorContainer = Color.Red,
border = colors.divider, border = colors.divider,
borderVariant = colors.divider, borderVariant = colors.divider,
scrim = Color.Black, scrim = Color.Black,
inverseSurface = Color.White, inverseSurface = Color.White,
inverseOnSurface = Color.Black, inverseOnSurface = Color.Black,
inversePrimary = colors.primary, inversePrimary = colors.primary,
surfaceTint = colors.primary surfaceTint = colors.primary
) )
val tvTypography = Typography( val tvTypography = Typography(
displayLarge = AppTypography.displayLarge, displayLarge = AppTypography.displayLarge,
displayMedium = AppTypography.displayMedium, displayMedium = AppTypography.displayMedium,
displaySmall = AppTypography.displayMedium, displaySmall = AppTypography.displayMedium,
headlineLarge = AppTypography.headlineLarge, headlineLarge = AppTypography.headlineLarge,
headlineMedium = AppTypography.headlineMedium, headlineMedium = AppTypography.headlineMedium,
headlineSmall = AppTypography.headlineMedium, headlineSmall = AppTypography.headlineMedium,
titleLarge = AppTypography.titleLarge, titleLarge = AppTypography.titleLarge,
titleMedium = AppTypography.titleMedium, titleMedium = AppTypography.titleMedium,
titleSmall = AppTypography.titleMedium, titleSmall = AppTypography.titleMedium,
bodyLarge = AppTypography.bodyLarge, bodyLarge = AppTypography.bodyLarge,
bodyMedium = AppTypography.bodyMedium, bodyMedium = AppTypography.bodyMedium,
bodySmall = AppTypography.bodyMedium, bodySmall = AppTypography.bodyMedium,
labelLarge = AppTypography.labelLarge, labelLarge = AppTypography.labelLarge,
labelMedium = AppTypography.labelLarge, labelMedium = AppTypography.labelLarge,
labelSmall = AppTypography.labelSmall labelSmall = AppTypography.labelSmall
) )
CompositionLocalProvider(LocalStreamFlowColors provides colors) { CompositionLocalProvider(LocalStreamFlowColors provides colors) {
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = tvTypography, typography = tvTypography,
content = content content = content
) )
} }
} }

View file

@ -1,59 +1,59 @@
package com.streamflow.tv.ui.theme package com.streamflow.tv.ui.theme
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
object AppTypography { object AppTypography {
val displayLarge = TextStyle( val displayLarge = TextStyle(
fontSize = 36.sp, fontSize = 36.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = TextPrimary, color = TextPrimary,
letterSpacing = (-0.5).sp letterSpacing = (-0.5).sp
) )
val displayMedium = TextStyle( val displayMedium = TextStyle(
fontSize = 28.sp, fontSize = 28.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = TextPrimary color = TextPrimary
) )
val headlineLarge = TextStyle( val headlineLarge = TextStyle(
fontSize = 24.sp, fontSize = 24.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = TextPrimary color = TextPrimary
) )
val headlineMedium = TextStyle( val headlineMedium = TextStyle(
fontSize = 20.sp, fontSize = 20.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = TextPrimary color = TextPrimary
) )
val titleLarge = TextStyle( val titleLarge = TextStyle(
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = TextPrimary color = TextPrimary
) )
val titleMedium = TextStyle( val titleMedium = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = TextPrimary color = TextPrimary
) )
val bodyLarge = TextStyle( val bodyLarge = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = TextSecondary color = TextSecondary
) )
val bodyMedium = TextStyle( val bodyMedium = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = TextSecondary color = TextSecondary
) )
val labelLarge = TextStyle( val labelLarge = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = TextPrimary color = TextPrimary
) )
val labelSmall = TextStyle( val labelSmall = TextStyle(
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = TextMuted color = TextMuted
) )
} }

View file

@ -1,45 +1,45 @@
package com.streamflow.tv.viewmodel package com.streamflow.tv.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.streamflow.tv.data.model.MovieDetail import com.streamflow.tv.data.model.MovieDetail
import com.streamflow.tv.data.repository.MovieRepository import com.streamflow.tv.data.repository.MovieRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class DetailUiState( data class DetailUiState(
val movie: MovieDetail? = null, val movie: MovieDetail? = null,
val isLoading: Boolean = true, val isLoading: Boolean = true,
val error: String? = null, val error: String? = null,
val isInMyList: Boolean = false val isInMyList: Boolean = false
) )
class DetailViewModel : ViewModel() { class DetailViewModel : ViewModel() {
private val repository = MovieRepository() private val repository = MovieRepository()
private val _uiState = MutableStateFlow(DetailUiState()) private val _uiState = MutableStateFlow(DetailUiState())
val uiState: StateFlow<DetailUiState> = _uiState val uiState: StateFlow<DetailUiState> = _uiState
fun loadMovie(slug: String) { fun loadMovie(slug: String) {
android.util.Log.e("DetailVM", "loadMovie($slug) called") android.util.Log.e("DetailVM", "loadMovie($slug) called")
viewModelScope.launch { viewModelScope.launch {
_uiState.value = DetailUiState(isLoading = true) _uiState.value = DetailUiState(isLoading = true)
try { try {
val movie = repository.getMovieDetail(slug) val movie = repository.getMovieDetail(slug)
android.util.Log.e("DetailVM", "loadMovie success: ${movie.title}, episodes: ${movie.episodes?.size}") android.util.Log.e("DetailVM", "loadMovie success: ${movie.title}, episodes: ${movie.episodes?.size}")
_uiState.value = DetailUiState(movie = movie, isLoading = false) _uiState.value = DetailUiState(movie = movie, isLoading = false)
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("DetailVM", "loadMovie failed", e) android.util.Log.e("DetailVM", "loadMovie failed", e)
_uiState.value = DetailUiState( _uiState.value = DetailUiState(
isLoading = false, isLoading = false,
error = e.message ?: "Failed to load movie details" error = e.message ?: "Failed to load movie details"
) )
} }
} }
} }
fun toggleMyList(isInList: Boolean) { fun toggleMyList(isInList: Boolean) {
_uiState.value = _uiState.value.copy(isInMyList = !isInList) _uiState.value = _uiState.value.copy(isInMyList = !isInList)
} }
} }

View file

@ -1,144 +1,110 @@
package com.streamflow.tv.viewmodel package com.streamflow.tv.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.streamflow.tv.data.model.Movie import com.streamflow.tv.data.model.Movie
import com.streamflow.tv.data.repository.MovieRepository import com.streamflow.tv.data.repository.MovieRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class HomeUiState( data class HomeUiState(
val heroMovies: List<Movie> = emptyList(), val heroMovies: List<Movie> = emptyList(),
val watchedMovies: List<Movie> = emptyList(), val watchedMovies: List<Movie> = emptyList(),
val recommendedMovies: List<Movie> = emptyList(), val recommendedMovies: List<Movie> = emptyList(),
val categoryMovies: Map<String, List<Movie>> = emptyMap(), val categoryMovies: Map<String, List<Movie>> = emptyMap(),
val isLoading: Boolean = true, val isLoading: Boolean = true,
val error: String? = null, val error: String? = null,
val currentCategory: String? = null val currentCategory: String? = null
) )
class HomeViewModel : ViewModel() { class HomeViewModel : ViewModel() {
private val repository = MovieRepository() private val repository = MovieRepository()
private val _uiState = MutableStateFlow(HomeUiState()) private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState val uiState: StateFlow<HomeUiState> = _uiState
private var userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null private var userDataRepository: com.streamflow.tv.data.repository.UserDataRepository? = null
private val categories = listOf( private val categories = listOf(
"phim-le" to "Phim Lẻ", "phim-le" to "Phim Lẻ",
"phim-bo" to "Phim Bộ", "phim-bo" to "Phim Bộ",
"hoat-hinh" to "Hoạt Hình", "hoat-hinh" to "Hoạt Hình",
"tv-shows" to "TV Shows" "tv-shows" to "TV Shows"
) )
init { init {
loadHome() loadHome()
} }
fun loadHome( fun loadHome(
category: String? = null, category: String? = null,
userRepo: com.streamflow.tv.data.repository.UserDataRepository? = null userRepo: com.streamflow.tv.data.repository.UserDataRepository? = null
) { ) {
if (userRepo != null) { if (userRepo != null) {
this.userDataRepository = userRepo this.userDataRepository = userRepo
} }
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, error = null, currentCategory = category) _uiState.value = _uiState.value.copy(isLoading = true, error = null, currentCategory = category)
try { try {
// Load history if repository is available // Load history if repository is available
val history = userRepo?.watchHistory?.first() ?: emptyList() val history = userRepo?.watchHistory?.first() ?: emptyList()
if (category != null) { if (category != null) {
// Load single category // Load single category
val response = repository.getHomeVideos(category) val response = repository.getHomeVideos(category)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
heroMovies = response.items.take(5), heroMovies = response.items.take(5),
watchedMovies = history, watchedMovies = history,
recommendedMovies = response.items.filter { m -> history.none { it.slug == m.slug } }.shuffled().take(10), recommendedMovies = response.items.filter { m -> history.none { it.slug == m.slug } }.shuffled().take(10),
categoryMovies = mapOf( categoryMovies = mapOf(
categories.find { it.first == category }?.second.orEmpty() to response.items categories.find { it.first == category }?.second.orEmpty() to response.items
), ),
isLoading = false isLoading = false
) )
} else { } else {
// Load all categories for home // Load all categories for home
val allMovies = java.util.Collections.synchronizedMap(mutableMapOf<String, List<Movie>>()) val allMovies = java.util.Collections.synchronizedMap(mutableMapOf<String, List<Movie>>())
val allFlattened = java.util.Collections.synchronizedList(mutableListOf<Movie>()) val allFlattened = java.util.Collections.synchronizedList(mutableListOf<Movie>())
kotlinx.coroutines.coroutineScope { kotlinx.coroutines.coroutineScope {
// 1. Initial categories // Load main categories only (to avoid OOM on TV devices)
val categoryTasks = categories.map { (slug, name) -> val categoryTasks = categories.map { (slug, name) ->
async { async {
try { try {
val response = repository.getHomeVideos(slug) val response = repository.getHomeVideos(slug)
allMovies[name] = response.items allMovies[name] = response.items.take(15)
allFlattened.addAll(response.items) allFlattened.addAll(response.items.take(15))
response.items response.items
} catch (_: Exception) { emptyList<Movie>() } } catch (_: Exception) { emptyList<Movie>() }
} }
} }
// 2. Fetch Genres & Countries metadata in parallel // Wait for categories
val genresDeferred = async { try { repository.getGenres().take(8) } catch (_: Exception) { emptyList() } } categoryTasks.awaitAll()
val countriesDeferred = async { try { repository.getCountries().take(5) } catch (_: Exception) { emptyList() } } }
val genres = genresDeferred.await() val heroItems = allMovies[categories.first().second]?.take(5) ?: emptyList()
val countries = countriesDeferred.await()
_uiState.value = _uiState.value.copy(
// 3. Fetch Genre and Country content in parallel heroMovies = heroItems,
val genreTasks = genres.map { genre -> watchedMovies = history,
async { recommendedMovies = allFlattened.filter { m -> history.none { it.slug == m.slug } }
try { .distinctBy { it.slug }.shuffled().take(15),
val response = repository.getHomeVideos(genre.slug) categoryMovies = allMovies.toMap(),
if (response.items.isNotEmpty()) { isLoading = false
allMovies["Genre: ${genre.name}"] = response.items )
allFlattened.addAll(response.items) }
} } catch (e: Exception) {
} catch (_: Exception) { } _uiState.value = _uiState.value.copy(
} isLoading = false,
} error = e.message ?: "Failed to load content"
)
val countryTasks = countries.map { country -> }
async { }
try { }
val response = repository.getHomeVideos(country.slug) }
if (response.items.isNotEmpty()) {
allMovies["Country: ${country.name}"] = response.items
allFlattened.addAll(response.items)
}
} catch (_: Exception) { }
}
}
// Wait for everything
categoryTasks.awaitAll()
genreTasks.awaitAll()
countryTasks.awaitAll()
}
val heroItems = allMovies[categories.first().second]?.take(5) ?: emptyList()
_uiState.value = _uiState.value.copy(
heroMovies = heroItems,
watchedMovies = history,
recommendedMovies = allFlattened.filter { m -> history.none { it.slug == m.slug } }
.distinctBy { it.slug }.shuffled().take(15),
categoryMovies = allMovies.toMap(),
isLoading = false
)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Failed to load content"
)
}
}
}
}

View file

@ -1,48 +1,48 @@
package com.streamflow.tv.viewmodel package com.streamflow.tv.viewmodel
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.streamflow.tv.data.model.Movie import com.streamflow.tv.data.model.Movie
import com.streamflow.tv.data.repository.UserDataRepository import com.streamflow.tv.data.repository.UserDataRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class MyListUiState( data class MyListUiState(
val savedMovies: List<Movie> = emptyList(), val savedMovies: List<Movie> = emptyList(),
val watchHistory: List<Movie> = emptyList() val watchHistory: List<Movie> = emptyList()
) )
class MyListViewModel(application: Application) : AndroidViewModel(application) { class MyListViewModel(application: Application) : AndroidViewModel(application) {
private val userRepo = UserDataRepository(application) private val userRepo = UserDataRepository(application)
private val _uiState = MutableStateFlow(MyListUiState()) private val _uiState = MutableStateFlow(MyListUiState())
val uiState: StateFlow<MyListUiState> = _uiState val uiState: StateFlow<MyListUiState> = _uiState
init { init {
viewModelScope.launch { viewModelScope.launch {
userRepo.myList.collectLatest { list -> userRepo.myList.collectLatest { list ->
_uiState.value = _uiState.value.copy(savedMovies = list) _uiState.value = _uiState.value.copy(savedMovies = list)
} }
} }
viewModelScope.launch { viewModelScope.launch {
userRepo.watchHistory.collectLatest { history -> userRepo.watchHistory.collectLatest { history ->
_uiState.value = _uiState.value.copy(watchHistory = history) _uiState.value = _uiState.value.copy(watchHistory = history)
} }
} }
} }
fun addToMyList(movie: Movie) { fun addToMyList(movie: Movie) {
viewModelScope.launch { userRepo.addToMyList(movie) } viewModelScope.launch { userRepo.addToMyList(movie) }
} }
fun removeFromMyList(slug: String) { fun removeFromMyList(slug: String) {
viewModelScope.launch { userRepo.removeFromMyList(slug) } viewModelScope.launch { userRepo.removeFromMyList(slug) }
} }
fun addToHistory(movie: Movie) { fun addToHistory(movie: Movie) {
viewModelScope.launch { userRepo.addToHistory(movie) } viewModelScope.launch { userRepo.addToHistory(movie) }
} }
} }

View file

@ -1,100 +1,100 @@
package com.streamflow.tv.viewmodel package com.streamflow.tv.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.streamflow.tv.data.model.MovieDetail import com.streamflow.tv.data.model.MovieDetail
import com.streamflow.tv.data.model.VideoSource import com.streamflow.tv.data.model.VideoSource
import com.streamflow.tv.data.repository.MovieRepository import com.streamflow.tv.data.repository.MovieRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class PlayerUiState( data class PlayerUiState(
val movie: MovieDetail? = null, val movie: MovieDetail? = null,
val source: VideoSource? = null, val source: VideoSource? = null,
val currentEpisode: Int = 1, val currentEpisode: Int = 1,
val isLoading: Boolean = true, val isLoading: Boolean = true,
val error: String? = null val error: String? = null
) )
class PlayerViewModel : ViewModel() { class PlayerViewModel : ViewModel() {
private val repository = MovieRepository() private val repository = MovieRepository()
private val _uiState = MutableStateFlow(PlayerUiState()) private val _uiState = MutableStateFlow(PlayerUiState())
val uiState: StateFlow<PlayerUiState> = _uiState val uiState: StateFlow<PlayerUiState> = _uiState
fun loadPlayer(slug: String, episode: Int = 1) { fun loadPlayer(slug: String, episode: Int = 1) {
viewModelScope.launch { viewModelScope.launch {
_uiState.value = PlayerUiState(isLoading = true, currentEpisode = episode) _uiState.value = PlayerUiState(isLoading = true, currentEpisode = episode)
try { try {
val movie = repository.getMovieDetail(slug) val movie = repository.getMovieDetail(slug)
_uiState.value = _uiState.value.copy(movie = movie) _uiState.value = _uiState.value.copy(movie = movie)
loadStream(movie, episode) loadStream(movie, episode)
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
error = e.message ?: "Failed to load" error = e.message ?: "Failed to load"
) )
} }
} }
} }
fun changeEpisode(episode: Int) { fun changeEpisode(episode: Int) {
val movie = _uiState.value.movie ?: return val movie = _uiState.value.movie ?: return
_uiState.value = _uiState.value.copy(currentEpisode = episode, isLoading = true, source = null) _uiState.value = _uiState.value.copy(currentEpisode = episode, isLoading = true, source = null)
viewModelScope.launch { viewModelScope.launch {
loadStream(movie, episode) loadStream(movie, episode)
} }
} }
fun saveToHistory(userDataRepository: com.streamflow.tv.data.repository.UserDataRepository) { fun saveToHistory(userDataRepository: com.streamflow.tv.data.repository.UserDataRepository) {
val movie = _uiState.value.movie ?: return val movie = _uiState.value.movie ?: return
viewModelScope.launch { viewModelScope.launch {
userDataRepository.addToHistory(movie.toMovie()) userDataRepository.addToHistory(movie.toMovie())
android.util.Log.e("PlayerViewModel", "Movie saved to history: ${movie.title}") android.util.Log.e("PlayerViewModel", "Movie saved to history: ${movie.title}")
} }
} }
private suspend fun loadStream(movie: MovieDetail, episode: Int) { private suspend fun loadStream(movie: MovieDetail, episode: Int) {
try { try {
val ep = movie.episodes?.find { it.number == episode } val ep = movie.episodes?.find { it.number == episode }
android.util.Log.e("PlayerViewModel", "Loading stream for slug=${movie.slug} episode=$episode. Episode data: $ep") android.util.Log.e("PlayerViewModel", "Loading stream for slug=${movie.slug} episode=$episode. Episode data: $ep")
if (ep != null && (ep.url.contains(".m3u8") || ep.url.contains("index.m3u8"))) { if (ep != null && (ep.url.contains(".m3u8") || ep.url.contains("index.m3u8"))) {
// Direct HLS URL // Direct HLS URL
android.util.Log.e("PlayerViewModel", "Direct HLS URL found: ${ep.url}") android.util.Log.e("PlayerViewModel", "Direct HLS URL found: ${ep.url}")
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
source = VideoSource( source = VideoSource(
streamUrl = ep.url, streamUrl = ep.url,
resolution = "HD", resolution = "HD",
formatId = "hls" formatId = "hls"
), ),
isLoading = false isLoading = false
) )
} else if (ep != null && ep.url.isNotEmpty()) { } else if (ep != null && ep.url.isNotEmpty()) {
// Non-HLS URL — try to extract via backend // Non-HLS URL — try to extract via backend
android.util.Log.e("PlayerViewModel", "Extracting from URL: ${ep.url}") android.util.Log.e("PlayerViewModel", "Extracting from URL: ${ep.url}")
val source = repository.extractVideo(ep.url) val source = repository.extractVideo(ep.url)
android.util.Log.e("PlayerViewModel", "Extraction successful: $source") android.util.Log.e("PlayerViewModel", "Extraction successful: $source")
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
source = source, source = source,
isLoading = false isLoading = false
) )
} else { } else {
// No valid episode URL found // No valid episode URL found
android.util.Log.e("PlayerViewModel", "No stream URL found for episode $episode") android.util.Log.e("PlayerViewModel", "No stream URL found for episode $episode")
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
error = "No stream available for episode $episode" error = "No stream available for episode $episode"
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("PlayerViewModel", "Error loading stream", e) android.util.Log.e("PlayerViewModel", "Error loading stream", e)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
isLoading = false, isLoading = false,
error = e.message ?: "Failed to extract stream" error = e.message ?: "Failed to extract stream"
) )
} }
} }
} }

View file

@ -1,39 +1,39 @@
package com.streamflow.tv.viewmodel package com.streamflow.tv.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.streamflow.tv.data.model.Movie import com.streamflow.tv.data.model.Movie
import com.streamflow.tv.data.repository.MovieRepository import com.streamflow.tv.data.repository.MovieRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class SearchUiState( data class SearchUiState(
val query: String = "", val query: String = "",
val results: List<Movie> = emptyList(), val results: List<Movie> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val hasSearched: Boolean = false val hasSearched: Boolean = false
) )
class SearchViewModel : ViewModel() { class SearchViewModel : ViewModel() {
private val repository = MovieRepository() private val repository = MovieRepository()
private val _uiState = MutableStateFlow(SearchUiState()) private val _uiState = MutableStateFlow(SearchUiState())
val uiState: StateFlow<SearchUiState> = _uiState val uiState: StateFlow<SearchUiState> = _uiState
fun search(query: String) { fun search(query: String) {
if (query.isBlank()) return if (query.isBlank()) return
_uiState.value = SearchUiState(query = query, isLoading = true, hasSearched = true) _uiState.value = SearchUiState(query = query, isLoading = true, hasSearched = true)
viewModelScope.launch { viewModelScope.launch {
try { try {
val response = repository.searchVideos(query) val response = repository.searchVideos(query)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
results = response.items, results = response.items,
isLoading = false isLoading = false
) )
} catch (e: Exception) { } catch (e: Exception) {
_uiState.value = _uiState.value.copy(isLoading = false) _uiState.value = _uiState.value.copy(isLoading = false)
} }
} }
} }
} }

View file

@ -1,33 +1,33 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp" android:width="320dp"
android:height="180dp" android:height="180dp"
android:viewportWidth="320" android:viewportWidth="320"
android:viewportHeight="180"> android:viewportHeight="180">
<!-- Background --> <!-- Background -->
<path <path
android:pathData="M0,0h320v180H0z" android:pathData="M0,0h320v180H0z"
android:fillColor="#141414"/> android:fillColor="#141414"/>
<!-- Gradient accent bar --> <!-- Gradient accent bar -->
<path <path
android:pathData="M0,160h320v20H0z" android:pathData="M0,160h320v20H0z"
android:fillColor="#06B6D4"/> android:fillColor="#06B6D4"/>
<!-- Icon circle --> <!-- Icon circle -->
<path <path
android:pathData="M160,75m-30,0a30,30 0,1 1,60 0a30,30 0,1 1,-60 0" android:pathData="M160,75m-30,0a30,30 0,1 1,60 0a30,30 0,1 1,-60 0"
android:fillColor="#06B6D4"/> android:fillColor="#06B6D4"/>
<!-- Play triangle --> <!-- Play triangle -->
<path <path
android:pathData="M152,60L172,75L152,90z" android:pathData="M152,60L172,75L152,90z"
android:fillColor="#FFFFFF"/> android:fillColor="#FFFFFF"/>
<!-- Text: StreamFlow --> <!-- Text: StreamFlow -->
<path <path
android:pathData="M95,130h130" android:pathData="M95,130h130"
android:strokeColor="#FFFFFF" android:strokeColor="#FFFFFF"
android:strokeWidth="0.5"/> android:strokeWidth="0.5"/>
</vector> </vector>

View file

@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp" android:width="48dp"
android:height="48dp" android:height="48dp"
android:viewportWidth="48" android:viewportWidth="48"
android:viewportHeight="48"> android:viewportHeight="48">
<!-- Background rounded rect --> <!-- Background rounded rect -->
<path <path
android:pathData="M8,0h32a8,8 0,0 1,8 8v32a8,8 0,0 1,-8 8H8A8,8 0,0 1,0 40V8A8,8 0,0 1,8 0z" android:pathData="M8,0h32a8,8 0,0 1,8 8v32a8,8 0,0 1,-8 8H8A8,8 0,0 1,0 40V8A8,8 0,0 1,8 0z"
android:fillColor="#06B6D4"/> android:fillColor="#06B6D4"/>
<!-- Play triangle --> <!-- Play triangle -->
<path <path
android:pathData="M18,12L36,24L18,36z" android:pathData="M18,12L36,24L18,36z"
android:fillColor="#FFFFFF"/> android:fillColor="#FFFFFF"/>
</vector> </vector>

View file

@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">StreamFlow</string> <string name="app_name">StreamFlow</string>
</resources> </resources>

View file

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.StreamFlowTV" parent="@style/Theme.Leanback"> <style name="Theme.StreamFlowTV" parent="@style/Theme.Leanback">
<item name="android:windowIsTranslucent">true</item> <item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item> <item name="android:windowBackground">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">false</item> <item name="android:backgroundDimEnabled">false</item>
</style> </style>
</resources> </resources>

View file

@ -1,4 +1,4 @@
plugins { plugins {
id("com.android.application") version "8.2.2" apply false id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false id("org.jetbrains.kotlin.android") version "1.9.22" apply false
} }

View file

@ -1,35 +1,35 @@
> Task :app:checkKotlinGradlePluginConfigurationErrors > Task :app:checkKotlinGradlePluginConfigurationErrors
> Task :app:preBuild UP-TO-DATE > Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE > Task :app:preDebugBuild UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE > Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE > Task :app:generateDebugResValues UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE > Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE > Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE > Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE > Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE > Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE > Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE > Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE > Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE > Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE > Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE > Task :app:processDebugResources UP-TO-DATE
> Task :app:compileDebugKotlin FAILED > Task :app:compileDebugKotlin FAILED
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:120:56 Unresolved reference: accent e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:120:56 Unresolved reference: accent
FAILURE: Build failed with an exception. FAILURE: Build failed with an exception.
* What went wrong: * What went wrong:
Execution failed for task ':app:compileDebugKotlin'. Execution failed for task ':app:compileDebugKotlin'.
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction > A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
> Compilation error. See log for more details > Compilation error. See log for more details
* Try: * Try:
> Run with --stacktrace option to get the stack trace. > Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output. > Run with --info or --debug option to get more log output.
> Run with --scan to get full insights. > Run with --scan to get full insights.
> Get more help at https://help.gradle.org. > Get more help at https://help.gradle.org.
BUILD FAILED in 3s BUILD FAILED in 3s
14 actionable tasks: 2 executed, 12 up-to-date 14 actionable tasks: 2 executed, 12 up-to-date

View file

@ -1,35 +1,35 @@
> Task :app:checkKotlinGradlePluginConfigurationErrors > Task :app:checkKotlinGradlePluginConfigurationErrors
> Task :app:preBuild UP-TO-DATE > Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE > Task :app:preDebugBuild UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE > Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE > Task :app:generateDebugResValues UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE > Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE > Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE > Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE > Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE > Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE > Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE > Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE > Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE > Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE > Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE > Task :app:processDebugResources UP-TO-DATE
> Task :app:compileDebugKotlin FAILED > Task :app:compileDebugKotlin FAILED
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:114:56 Unresolved reference: accent e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:114:56 Unresolved reference: accent
FAILURE: Build failed with an exception. FAILURE: Build failed with an exception.
* What went wrong: * What went wrong:
Execution failed for task ':app:compileDebugKotlin'. Execution failed for task ':app:compileDebugKotlin'.
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction > A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
> Compilation error. See log for more details > Compilation error. See log for more details
* Try: * Try:
> Run with --stacktrace option to get the stack trace. > Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output. > Run with --info or --debug option to get more log output.
> Run with --scan to get full insights. > Run with --scan to get full insights.
> Get more help at https://help.gradle.org. > Get more help at https://help.gradle.org.
BUILD FAILED in 1s BUILD FAILED in 1s
14 actionable tasks: 2 executed, 12 up-to-date 14 actionable tasks: 2 executed, 12 up-to-date

View file

@ -1,37 +1,37 @@
> Task :app:checkKotlinGradlePluginConfigurationErrors > Task :app:checkKotlinGradlePluginConfigurationErrors
> Task :app:preBuild UP-TO-DATE > Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE > Task :app:preDebugBuild UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE > Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE > Task :app:generateDebugResValues UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE > Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE > Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE > Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE > Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE > Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests UP-TO-DATE > Task :app:createDebugCompatibleScreenManifests UP-TO-DATE
> Task :app:extractDeepLinksDebug UP-TO-DATE > Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest UP-TO-DATE > Task :app:processDebugMainManifest UP-TO-DATE
> Task :app:processDebugManifest UP-TO-DATE > Task :app:processDebugManifest UP-TO-DATE
> Task :app:processDebugManifestForPackage UP-TO-DATE > Task :app:processDebugManifestForPackage UP-TO-DATE
> Task :app:processDebugResources UP-TO-DATE > Task :app:processDebugResources UP-TO-DATE
> Task :app:compileDebugKotlin FAILED > Task :app:compileDebugKotlin FAILED
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:140:21 Cannot find a parameter with this name: onEpisodeClick e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:140:21 Cannot find a parameter with this name: onEpisodeClick
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:140:40 Cannot infer a type for this parameter. Please specify it explicitly. e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:140:40 Cannot infer a type for this parameter. Please specify it explicitly.
e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:141:21 No value passed for parameter 'onEpisodeSelect' e: file:///C:/Users/Admin/Documents/Streamflow/android-tv/app/src/main/java/com/streamflow/tv/ui/screens/DetailScreen.kt:141:21 No value passed for parameter 'onEpisodeSelect'
FAILURE: Build failed with an exception. FAILURE: Build failed with an exception.
* What went wrong: * What went wrong:
Execution failed for task ':app:compileDebugKotlin'. Execution failed for task ':app:compileDebugKotlin'.
> A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction > A failure occurred while executing org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction
> Compilation error. See log for more details > Compilation error. See log for more details
* Try: * Try:
> Run with --stacktrace option to get the stack trace. > Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output. > Run with --info or --debug option to get more log output.
> Run with --scan to get full insights. > Run with --scan to get full insights.
> Get more help at https://help.gradle.org. > Get more help at https://help.gradle.org.
BUILD FAILED in 1s BUILD FAILED in 1s
14 actionable tasks: 2 executed, 12 up-to-date 14 actionable tasks: 2 executed, 12 up-to-date

299
android-tv/build_error.txt Normal file
View 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

View file

@ -1,92 +1,92 @@
@rem @rem
@rem Copyright 2015 the original author or authors. @rem Copyright 2015 the original author or authors.
@rem @rem
@rem Licensed under the Apache License, Version 2.0 (the "License"); @rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License. @rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at @rem You may obtain a copy of the License at
@rem @rem
@rem https://www.apache.org/licenses/LICENSE-2.0 @rem https://www.apache.org/licenses/LICENSE-2.0
@rem @rem
@rem Unless required by applicable law or agreed to in writing, software @rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS, @rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and @rem See the License for the specific language governing permissions and
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem Gradle startup script for Windows
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused @rem This is normally unused
set APP_BASE_NAME=%~n0 set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%.. set APP_HOME=%DIRNAME%..
@rem Resolve any "." and ".." in APP_HOME to make it shorter. @rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" "-javaagent:%APP_HOME%/lib/agents/gradle-instrumentation-agent-8.4.jar" set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" "-javaagent:%APP_HOME%/lib/agents/gradle-instrumentation-agent-8.4.jar"
@rem Find java.exe @rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute if %ERRORLEVEL% equ 0 goto execute
echo. echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo. echo.
echo Please set the JAVA_HOME variable in your environment to match the echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation. echo location of your Java installation.
goto fail goto fail
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto execute
echo. echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo. echo.
echo Please set the JAVA_HOME variable in your environment to match the echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation. echo location of your Java installation.
goto fail goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\lib\gradle-launcher-8.4.jar set CLASSPATH=%APP_HOME%\lib\gradle-launcher-8.4.jar
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.launcher.GradleMain %* "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.launcher.GradleMain %*
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd if %ERRORLEVEL% equ 0 goto mainEnd
:fail :fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code! rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL% set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1 if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE% exit /b %EXIT_CODE%
:mainEnd :mainEnd
if "%OS%"=="Windows_NT" endlocal if "%OS%"=="Windows_NT" endlocal
:omega :omega

View file

@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true android.useAndroidX=true
kotlin.code.style=official kotlin.code.style=official
android.nonTransitiveRClass=true android.nonTransitiveRClass=true

0
android-tv/gradlew vendored Normal file → Executable file
View file

188
android-tv/gradlew.bat vendored
View file

@ -1,94 +1,94 @@
@rem @rem
@rem Copyright 2015 the original author or authors. @rem Copyright 2015 the original author or authors.
@rem @rem
@rem Licensed under the Apache License, Version 2.0 (the "License"); @rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License. @rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at @rem You may obtain a copy of the License at
@rem @rem
@rem https://www.apache.org/licenses/LICENSE-2.0 @rem https://www.apache.org/licenses/LICENSE-2.0
@rem @rem
@rem Unless required by applicable law or agreed to in writing, software @rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS, @rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and @rem See the License for the specific language governing permissions and
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@rem SPDX-License-Identifier: Apache-2.0 @rem SPDX-License-Identifier: Apache-2.0
@rem @rem
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem Gradle startup script for Windows
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused @rem This is normally unused
set APP_BASE_NAME=%~n0 set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME% set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter. @rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe @rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2 echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2 echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail goto fail
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto execute
echo. 1>&2 echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2 echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd if %ERRORLEVEL% equ 0 goto mainEnd
:fail :fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code! rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL% set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1 if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE% exit /b %EXIT_CODE%
:mainEnd :mainEnd
if "%OS%"=="Windows_NT" endlocal if "%OS%"=="Windows_NT" endlocal
:omega :omega

46385
android-tv/logcat.txt Normal file

File diff suppressed because it is too large Load diff

2151
android-tv/logcat2.txt Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,18 +1,18 @@
pluginManagement { pluginManagement {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
gradlePluginPortal() gradlePluginPortal()
} }
} }
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
} }
rootProject.name = "StreamFlowTV" rootProject.name = "StreamFlowTV"
include(":app") include(":app")

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -75,6 +75,7 @@ func (h *Handler) GetHomeVideos(w http.ResponseWriter, r *http.Request) {
return p.GetMoviesByCategory(category, page) return p.GetMoviesByCategory(category, page)
}) })
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(movies) json.NewEncoder(w).Encode(movies)
} }
@ -94,6 +95,7 @@ func (h *Handler) SearchVideos(w http.ResponseWriter, r *http.Request) {
return p.Search(query, page) return p.Search(query, page)
}) })
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(movies) json.NewEncoder(w).Encode(movies)
} }
@ -211,6 +213,7 @@ func (h *Handler) ExtractVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(info) json.NewEncoder(w).Encode(info)
} }
@ -296,16 +299,18 @@ func (h *Handler) GetMovieDetail(w http.ResponseWriter, r *http.Request) {
if len(primaryMovie.Episodes) > 0 { if len(primaryMovie.Episodes) > 0 {
uniqueEps := make([]models.Episode, 0) uniqueEps := make([]models.Episode, 0)
seenEpNums := make(map[int]bool) seenEpNums := make(map[string]bool)
for _, ep := range primaryMovie.Episodes { for _, ep := range primaryMovie.Episodes {
if !seenEpNums[ep.Number] { key := fmt.Sprintf("%d-%s", ep.Number, ep.ServerName)
seenEpNums[ep.Number] = true if !seenEpNums[key] {
seenEpNums[key] = true
uniqueEps = append(uniqueEps, ep) uniqueEps = append(uniqueEps, ep)
} }
} }
primaryMovie.Episodes = uniqueEps primaryMovie.Episodes = uniqueEps
} }
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(primaryMovie) json.NewEncoder(w).Encode(primaryMovie)
} }
@ -316,6 +321,7 @@ func (h *Handler) GetGenres(w http.ResponseWriter, r *http.Request) {
}); ok { }); ok {
genres, err := gp.GetGenres() genres, err := gp.GetGenres()
if err == nil { if err == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(genres) json.NewEncoder(w).Encode(genres)
return return
} }
@ -331,6 +337,7 @@ func (h *Handler) GetCountries(w http.ResponseWriter, r *http.Request) {
}); ok { }); ok {
countries, err := cp.GetCountries() countries, err := cp.GetCountries()
if err == nil { if err == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(countries) json.NewEncoder(w).Encode(countries)
return return
} }
@ -425,21 +432,23 @@ func (h *Handler) mergeMovieMetadata(existing, new *models.RophimMovie) {
existing.Quality = new.Quality existing.Quality = new.Quality
} }
epMap := make(map[int]int) epMap := make(map[string]int)
for i := range existing.Episodes { for i, ep := range existing.Episodes {
epMap[existing.Episodes[i].Number] = i key := fmt.Sprintf("%d-%s", ep.Number, ep.ServerName)
epMap[key] = i
} }
for i := range new.Episodes { for i := range new.Episodes {
newEp := &new.Episodes[i] newEp := &new.Episodes[i]
if idx, exists := epMap[newEp.Number]; exists { key := fmt.Sprintf("%d-%s", newEp.Number, newEp.ServerName)
if idx, exists := epMap[key]; exists {
if existing.Episodes[idx].URL == "" && newEp.URL != "" { if existing.Episodes[idx].URL == "" && newEp.URL != "" {
existing.Episodes[idx].URL = newEp.URL existing.Episodes[idx].URL = newEp.URL
existing.Episodes[idx].Title = newEp.Title existing.Episodes[idx].Title = newEp.Title
existing.Episodes[idx].ServerName = newEp.ServerName existing.Episodes[idx].ServerName = newEp.ServerName
} }
} else { } else {
epMap[newEp.Number] = len(existing.Episodes) epMap[key] = len(existing.Episodes)
existing.Episodes = append(existing.Episodes, *newEp) existing.Episodes = append(existing.Episodes, *newEp)
} }
} }

View file

@ -1,16 +1,16 @@
package api package api
import ( import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
func RegisterRoutes(r chi.Router, h *Handler) { func RegisterRoutes(r chi.Router, h *Handler) {
r.Get("/videos/home", h.GetHomeVideos) r.Get("/videos/home", h.GetHomeVideos)
r.Get("/videos/search", h.SearchVideos) r.Get("/videos/search", h.SearchVideos)
r.Get("/videos/{slug}", h.GetMovieDetail) r.Get("/videos/{slug}", h.GetMovieDetail)
r.Post("/extract", h.ExtractVideo) r.Post("/extract", h.ExtractVideo)
r.Get("/images/proxy", h.ProxyImage) r.Get("/images/proxy", h.ProxyImage)
r.Get("/categories/genres", h.GetGenres) r.Get("/categories/genres", h.GetGenres)
r.Get("/categories/countries", h.GetCountries) r.Get("/categories/countries", h.GetCountries)
r.Get("/stream", h.StreamVideo) r.Get("/stream", h.StreamVideo)
} }

View file

@ -1,85 +1,85 @@
package database package database
import ( import (
"log" "log"
"streamflow-backend/internal/models" "streamflow-backend/internal/models"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
) )
var DB *gorm.DB var DB *gorm.DB
func InitDB(dsn string) { func InitDB(dsn string) {
var err error var err error
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{ DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info), Logger: logger.Default.LogMode(logger.Info),
}) })
if err != nil { if err != nil {
log.Fatal("Failed to connect to database:", err) log.Fatal("Failed to connect to database:", err)
} }
log.Println("Database connection established") log.Println("Database connection established")
// Auto-migrate schema // Auto-migrate schema
err = DB.AutoMigrate(&models.Video{}) err = DB.AutoMigrate(&models.Video{})
if err != nil { if err != nil {
log.Fatal("Failed to migrate database:", err) log.Fatal("Failed to migrate database:", err)
} }
} }
type VideoRepository struct { type VideoRepository struct {
db *gorm.DB db *gorm.DB
} }
func NewVideoRepository(db *gorm.DB) *VideoRepository { func NewVideoRepository(db *gorm.DB) *VideoRepository {
return &VideoRepository{db: db} return &VideoRepository{db: db}
} }
func (r *VideoRepository) Create(video *models.Video) error { func (r *VideoRepository) Create(video *models.Video) error {
return r.db.Create(video).Error return r.db.Create(video).Error
} }
func (r *VideoRepository) GetByID(id uint) (*models.Video, error) { func (r *VideoRepository) GetByID(id uint) (*models.Video, error) {
var video models.Video var video models.Video
err := r.db.First(&video, id).Error err := r.db.First(&video, id).Error
return &video, err return &video, err
} }
func (r *VideoRepository) GetBySourceURL(url string) (*models.Video, error) { func (r *VideoRepository) GetBySourceURL(url string) (*models.Video, error) {
var video models.Video var video models.Video
err := r.db.Where("source_url = ?", url).First(&video).Error err := r.db.Where("source_url = ?", url).First(&video).Error
return &video, err return &video, err
} }
func (r *VideoRepository) Search(query string, limit int) ([]models.Video, error) { func (r *VideoRepository) Search(query string, limit int) ([]models.Video, error) {
var videos []models.Video var videos []models.Video
err := r.db.Where("title LIKE ?", "%"+query+"%").Limit(limit).Find(&videos).Error err := r.db.Where("title LIKE ?", "%"+query+"%").Limit(limit).Find(&videos).Error
return videos, err return videos, err
} }
func (r *VideoRepository) GetAll(skip int, limit int) ([]models.Video, error) { func (r *VideoRepository) GetAll(skip int, limit int) ([]models.Video, error) {
var videos []models.Video var videos []models.Video
err := r.db.Offset(skip).Limit(limit).Find(&videos).Error err := r.db.Offset(skip).Limit(limit).Find(&videos).Error
return videos, err return videos, err
} }
func (r *VideoRepository) Update(id uint, updates map[string]interface{}) (*models.Video, error) { func (r *VideoRepository) Update(id uint, updates map[string]interface{}) (*models.Video, error) {
var video models.Video var video models.Video
result := r.db.First(&video, id) result := r.db.First(&video, id)
if result.Error != nil { if result.Error != nil {
return nil, result.Error return nil, result.Error
} }
err := r.db.Model(&video).Updates(updates).Error err := r.db.Model(&video).Updates(updates).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &video, nil return &video, nil
} }
func (r *VideoRepository) Delete(id uint) error { func (r *VideoRepository) Delete(id uint) error {
return r.db.Delete(&models.Video{}, id).Error return r.db.Delete(&models.Video{}, id).Error
} }

View file

@ -1,56 +1,56 @@
package models package models
import ( import (
"time" "time"
) )
// Video metadata model matches SQLAlchemy Video class // Video metadata model matches SQLAlchemy Video class
type Video struct { type Video struct {
ID uint `json:"id" gorm:"primaryKey"` ID uint `json:"id" gorm:"primaryKey"`
Title string `json:"title" gorm:"index;size:500"` Title string `json:"title" gorm:"index;size:500"`
Description string `json:"description"` Description string `json:"description"`
Thumbnail string `json:"thumbnail" gorm:"size:1000"` Thumbnail string `json:"thumbnail" gorm:"size:1000"`
SourceURL string `json:"source_url" gorm:"uniqueIndex;size:2000"` SourceURL string `json:"source_url" gorm:"uniqueIndex;size:2000"`
Duration int `json:"duration" gorm:"default:0"` Duration int `json:"duration" gorm:"default:0"`
Resolution string `json:"resolution" gorm:"size:20"` Resolution string `json:"resolution" gorm:"size:20"`
Category string `json:"category" gorm:"index;size:100"` Category string `json:"category" gorm:"index;size:100"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// RophimMovie represents the scraped movie data // RophimMovie represents the scraped movie data
type RophimMovie struct { type RophimMovie struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
OriginalTitle string `json:"original_title,omitempty"` OriginalTitle string `json:"original_title,omitempty"`
Slug string `json:"slug"` Slug string `json:"slug"`
Thumbnail string `json:"thumbnail"` Thumbnail string `json:"thumbnail"`
Backdrop string `json:"backdrop,omitempty"` Backdrop string `json:"backdrop,omitempty"`
Year int `json:"year,omitempty"` Year int `json:"year,omitempty"`
Rating string `json:"rating,omitempty"` Rating string `json:"rating,omitempty"`
Duration int `json:"duration,omitempty"` Duration int `json:"duration,omitempty"`
Time string `json:"time,omitempty"` // Raw time string Time string `json:"time,omitempty"` // Raw time string
Quality string `json:"quality,omitempty"` Quality string `json:"quality,omitempty"`
Lang string `json:"lang,omitempty"` Lang string `json:"lang,omitempty"`
Genre string `json:"genre,omitempty"` Genre string `json:"genre,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Category string `json:"category"` Category string `json:"category"`
Provider string `json:"provider,omitempty"` Provider string `json:"provider,omitempty"`
Cast []string `json:"cast,omitempty" gorm:"-"` Cast []string `json:"cast,omitempty" gorm:"-"`
Director string `json:"director,omitempty"` Director string `json:"director,omitempty"`
Country string `json:"country,omitempty"` Country string `json:"country,omitempty"`
Episodes []Episode `json:"episodes,omitempty" gorm:"-"` Episodes []Episode `json:"episodes,omitempty" gorm:"-"`
TrailerURL string `json:"trailer_url,omitempty"` TrailerURL string `json:"trailer_url,omitempty"`
} }
type Episode struct { type Episode struct {
Number int `json:"number"` Number int `json:"number"`
Title string `json:"title"` Title string `json:"title"`
URL string `json:"url"` URL string `json:"url"`
ServerName string `json:"server_name"` ServerName string `json:"server_name"`
} }
type Category struct { type Category struct {
Name string `json:"name"` Name string `json:"name"`
Slug string `json:"slug"` Slug string `json:"slug"`
} }

View file

@ -220,12 +220,12 @@ func (s *OphimScraper) fetchAndParseList(url string) ([]models.RophimMovie, erro
thumb := item.ThumbURL thumb := item.ThumbURL
if !strings.HasPrefix(thumb, "http") { if !strings.HasPrefix(thumb, "http") {
// Search API might return relative paths too // Search API might return relative paths too
thumb = "https://img.ophim1.com/uploads/movies/" + thumb thumb = "https://img.ophim.live/uploads/movies/" + thumb
} }
backdrop := item.PosterURL backdrop := item.PosterURL
if !strings.HasPrefix(backdrop, "http") { if !strings.HasPrefix(backdrop, "http") {
backdrop = "https://img.ophim1.com/uploads/movies/" + backdrop backdrop = "https://img.ophim.live/uploads/movies/" + backdrop
} }
movies = append(movies, models.RophimMovie{ movies = append(movies, models.RophimMovie{
@ -273,12 +273,12 @@ func (s *OphimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error)
thumb := movie.ThumbURL thumb := movie.ThumbURL
if !strings.HasPrefix(thumb, "http") { if !strings.HasPrefix(thumb, "http") {
thumb = "https://img.ophim1.com/uploads/movies/" + thumb thumb = "https://img.ophim.live/uploads/movies/" + thumb
} }
backdrop := movie.PosterURL backdrop := movie.PosterURL
if !strings.HasPrefix(backdrop, "http") { if !strings.HasPrefix(backdrop, "http") {
backdrop = "https://img.ophim1.com/uploads/movies/" + backdrop backdrop = "https://img.ophim.live/uploads/movies/" + backdrop
} }
var episodes []models.Episode var episodes []models.Episode

View file

@ -1,191 +1,237 @@
package scraper package scraper
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"streamflow-backend/internal/models" "streamflow-backend/internal/models"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
func parseEpisodeNumber(title string) int { func parseEpisodeNumber(title string) int {
// e.g "Tập 1", "Tập 01", "Full" // e.g "Tập 1", "Tập 01", "Full"
t := strings.ToLower(strings.TrimSpace(title)) t := strings.ToLower(strings.TrimSpace(title))
if t == "full" { if t == "full" {
return 1 return 1
} }
t = strings.ReplaceAll(t, "tập ", "") t = strings.ReplaceAll(t, "tập ", "")
t = strings.ReplaceAll(t, "tap ", "") t = strings.ReplaceAll(t, "tap ", "")
// handle multi-spaces // handle multi-spaces
parts := strings.Fields(t) parts := strings.Fields(t)
if len(parts) > 0 { if len(parts) > 0 {
num, err := strconv.Atoi(parts[0]) num, err := strconv.Atoi(parts[0])
if err == nil { if err == nil {
return num return num
} }
} }
return 1 return 1
} }
const Phim30BaseURL = "https://phim30.me" const Phim30BaseURL = "https://phim30.me"
type Phim30Scraper struct { type Phim30Scraper struct {
client *http.Client client *http.Client
} }
func NewPhim30Scraper() *Phim30Scraper { func NewPhim30Scraper() *Phim30Scraper {
return &Phim30Scraper{ return &Phim30Scraper{
client: &http.Client{ client: &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
}, },
} }
} }
func (p *Phim30Scraper) Search(query string, page int) ([]models.RophimMovie, error) { func (p *Phim30Scraper) Search(query string, page int) ([]models.RophimMovie, error) {
searchURL := fmt.Sprintf("%s/tim-kiem?keyword=%s&page=%d", Phim30BaseURL, url.QueryEscape(query), page) searchURL := fmt.Sprintf("%s/tim-kiem?keyword=%s&page=%d", Phim30BaseURL, url.QueryEscape(query), page)
return p.scrapeMovieList(searchURL) return p.scrapeMovieList(searchURL)
} }
func (p *Phim30Scraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) { func (p *Phim30Scraper) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) {
// e.g. https://phim30.me/the-loai/hanh-dong?page=1 if category == "" || category == "home" {
catURL := fmt.Sprintf("%s/the-loai/%s?page=%d", Phim30BaseURL, category, page) homeURL := fmt.Sprintf("%s/?page=%d", Phim30BaseURL, page)
return p.scrapeMovieList(catURL) return p.scrapeMovieList(homeURL)
} }
func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie, error) { var path string
req, err := http.NewRequest("GET", targetURL, nil) switch category {
if err != nil { case "phim-le", "phim-bo", "phim-sap-chieu":
return nil, err path = fmt.Sprintf("danh-sach/%s", category)
} default:
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") // Assume everything else is a Genre (e.g., hanh-dong, hoat-hinh, tv-shows)
path = fmt.Sprintf("the-loai/%s", category)
resp, err := p.client.Do(req) }
if err != nil {
return nil, err catURL := fmt.Sprintf("%s/%s?page=%d", Phim30BaseURL, path, page)
} return p.scrapeMovieList(catURL)
defer resp.Body.Close() }
if resp.StatusCode != http.StatusOK { func cleanImageUrl(rawURL string) string {
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode) if strings.Contains(rawURL, "cdn-image-tf.phim30.me") {
} // Example: https://cdn-image-tf.phim30.me/unsafe/360x0/filters:quality(90)/https%3A%2F%2Fphimimg.com%2Fupload%2Fvod%2F...
parts := strings.SplitN(rawURL, "/https", 2)
doc, err := goquery.NewDocumentFromReader(resp.Body) if len(parts) == 2 {
if err != nil { decoded, err := url.QueryUnescape("https" + parts[1])
return nil, err if err == nil {
} return decoded
}
var movies []models.RophimMovie }
}
doc.Find("a[href^='https://phim30.me/phim/']").Each(func(i int, s *goquery.Selection) { return rawURL
href, _ := s.Attr("href") }
title, _ := s.Attr("title")
func (p *Phim30Scraper) scrapeMovieList(targetURL string) ([]models.RophimMovie, error) {
// Remove the base url to get the slug req, err := http.NewRequest("GET", targetURL, nil)
slug := strings.TrimPrefix(href, "https://phim30.me/phim/") if err != nil {
return nil, err
// Try to find an image child (check data-src for lazy-loaded images) }
thumb := "" req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
s.Find("img").Each(func(j int, img *goquery.Selection) {
src, _ := img.Attr("src") resp, err := p.client.Do(req)
dataSrc, _ := img.Attr("data-src") if err != nil {
lazySrc, _ := img.Attr("lazy-src") return nil, err
if dataSrc != "" { }
thumb = dataSrc defer resp.Body.Close()
} else if lazySrc != "" {
thumb = lazySrc if resp.StatusCode != http.StatusOK {
} else if src != "" && !strings.Contains(src, "data:image") { return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
thumb = src }
}
}) doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
if title != "" && slug != "" { return nil, err
movies = append(movies, models.RophimMovie{ }
ID: slug,
Slug: slug, var movies []models.RophimMovie
Title: title,
OriginalTitle: title, doc.Find("a[href^='https://phim30.me/phim/']").Each(func(i int, s *goquery.Selection) {
Thumbnail: thumb, href, _ := s.Attr("href")
}) title, _ := s.Attr("title")
}
}) if title == "" {
title = strings.TrimSpace(s.Text())
// Deduplicate movies because a search page might have multiple links to the same movie }
var uniqueMovies []models.RophimMovie
seen := make(map[string]bool) // Remove the base url to get the slug
for _, m := range movies { slug := strings.TrimPrefix(href, "https://phim30.me/phim/")
if !seen[m.Slug] {
seen[m.Slug] = true // Try to find an image child (check data-src for lazy-loaded images)
uniqueMovies = append(uniqueMovies, m) thumb := ""
} s.Find("img").Each(func(j int, img *goquery.Selection) {
} src, _ := img.Attr("src")
dataSrc, _ := img.Attr("data-src")
return uniqueMovies, nil lazySrc, _ := img.Attr("lazy-src")
} if dataSrc != "" {
thumb = dataSrc
func (p *Phim30Scraper) GetMovieDetail(slug string) (*models.RophimMovie, error) { } else if lazySrc != "" {
targetURL := fmt.Sprintf("%s/phim/%s", Phim30BaseURL, slug) thumb = lazySrc
req, err := http.NewRequest("GET", targetURL, nil) } else if src != "" && !strings.Contains(src, "data:image") {
if err != nil { thumb = src
return nil, err }
} })
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
if title != "" && slug != "" && !strings.Contains(slug, "the-loai") && !strings.Contains(slug, "quoc-gia") && !strings.Contains(slug, "nam-phat-hanh") {
resp, err := p.client.Do(req) movies = append(movies, models.RophimMovie{
if err != nil { ID: slug,
return nil, err Slug: slug,
} Title: title,
defer resp.Body.Close() OriginalTitle: title,
Thumbnail: cleanImageUrl(thumb),
if resp.StatusCode != http.StatusOK { Backdrop: cleanImageUrl(thumb),
return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode) Provider: "Phim30.me",
} })
}
doc, err := goquery.NewDocumentFromReader(resp.Body) })
if err != nil {
return nil, err // Deduplicate movies because a search page might have multiple links to the same movie
} var uniqueMovies []models.RophimMovie
seen := make(map[string]bool)
movie := &models.RophimMovie{ for _, m := range movies {
ID: slug, if !seen[m.Slug] {
Slug: slug, seen[m.Slug] = true
} uniqueMovies = append(uniqueMovies, m)
}
title := doc.Find("h1.movie-title").Text() }
if title == "" {
title = doc.Find("title").Text() return uniqueMovies, nil
title = strings.Split(title, "")[0] }
title = strings.TrimSpace(title)
} func (p *Phim30Scraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
movie.Title = title targetURL := fmt.Sprintf("%s/phim/%s", Phim30BaseURL, slug)
movie.OriginalTitle = title req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
var eps []models.Episode return nil, err
doc.Find("a[href*='/xem-phim/']").Each(func(i int, s *goquery.Selection) { }
href, _ := s.Attr("href") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
epName := strings.TrimSpace(s.Text())
resp, err := p.client.Do(req)
if epName != "" && href != "" { if err != nil {
if !strings.HasPrefix(href, "http") { return nil, err
href = Phim30BaseURL + href }
} defer resp.Body.Close()
eps = append(eps, models.Episode{
ServerName: "Phim30", if resp.StatusCode != http.StatusOK {
Title: epName, return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
Number: parseEpisodeNumber(epName), }
URL: href,
}) doc, err := goquery.NewDocumentFromReader(resp.Body)
} if err != nil {
}) return nil, err
}
if len(eps) > 0 {
movie.Episodes = eps movie := &models.RophimMovie{
} ID: slug,
Slug: slug,
return movie, nil }
}
title := doc.Find("h1.movie-title").Text()
if title == "" {
title = doc.Find("title").Text()
title = strings.Split(title, "")[0]
title = strings.TrimSpace(title)
}
movie.Title = title
movie.OriginalTitle = title
thumb := ""
doc.Find("div.movie-l-img img").Each(func(i int, img *goquery.Selection) {
if src, ok := img.Attr("src"); ok {
thumb = src
}
})
if thumb != "" {
movie.Thumbnail = cleanImageUrl(thumb)
movie.Backdrop = cleanImageUrl(thumb)
}
movie.Provider = "Phim30.me"
var eps []models.Episode
doc.Find("a[href*='/xem-phim/']").Each(func(i int, s *goquery.Selection) {
href, _ := s.Attr("href")
epName := strings.TrimSpace(s.Text())
if epName != "" && href != "" {
if !strings.HasPrefix(href, "http") {
href = Phim30BaseURL + href
}
eps = append(eps, models.Episode{
ServerName: "Phim30",
Title: epName,
Number: parseEpisodeNumber(epName),
URL: href,
})
}
})
if len(eps) > 0 {
movie.Episodes = eps
}
return movie, nil
}

View file

@ -1,9 +1,9 @@
package scraper package scraper
import "streamflow-backend/internal/models" import "streamflow-backend/internal/models"
type MovieProvider interface { type MovieProvider interface {
GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error) GetMoviesByCategory(category string, page int) ([]models.RophimMovie, error)
GetMovieDetail(slug string) (*models.RophimMovie, error) GetMovieDetail(slug string) (*models.RophimMovie, error)
Search(query string, page int) ([]models.RophimMovie, error) Search(query string, page int) ([]models.RophimMovie, error)
} }

View file

@ -1,246 +1,246 @@
package scraper package scraper
import ( import (
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"streamflow-backend/internal/models" "streamflow-backend/internal/models"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
) )
const BaseURL = "https://phimmoichill.network" const BaseURL = "https://phimmoichill.network"
type RophimScraper struct { type RophimScraper struct {
client *http.Client client *http.Client
} }
func NewRophimScraper() *RophimScraper { func NewRophimScraper() *RophimScraper {
// Create custom client to handle SSL constraints if needed, similar to Python's ssl_context // Create custom client to handle SSL constraints if needed, similar to Python's ssl_context
tr := &http.Transport{ tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
} }
client := &http.Client{ client := &http.Client{
Transport: tr, Transport: tr,
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
} }
return &RophimScraper{client: client} return &RophimScraper{client: client}
} }
func (s *RophimScraper) fetchDocument(url string) (*goquery.Document, error) { func (s *RophimScraper) fetchDocument(url string) (*goquery.Document, error) {
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Referer", BaseURL) req.Header.Set("Referer", BaseURL)
resp, err := s.client.Do(req) resp, err := s.client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status) return nil, fmt.Errorf("status code error: %d %s", resp.StatusCode, resp.Status)
} }
return goquery.NewDocumentFromReader(resp.Body) return goquery.NewDocumentFromReader(resp.Body)
} }
func (s *RophimScraper) GetHomepageMovies(page int, limit int) ([]models.RophimMovie, error) { func (s *RophimScraper) GetHomepageMovies(page int, limit int) ([]models.RophimMovie, error) {
url := fmt.Sprintf("%s/danh-sach/phim-le", BaseURL) url := fmt.Sprintf("%s/danh-sach/phim-le", BaseURL)
if page > 1 { if page > 1 {
url = fmt.Sprintf("%s/danh-sach/phim-le/page/%d", BaseURL, page) url = fmt.Sprintf("%s/danh-sach/phim-le/page/%d", BaseURL, page)
} }
doc, err := s.fetchDocument(url) doc, err := s.fetchDocument(url)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.parseMovieGrid(doc, limit), nil return s.parseMovieGrid(doc, limit), nil
} }
func (s *RophimScraper) Search(query string, limit int) ([]models.RophimMovie, error) { func (s *RophimScraper) Search(query string, limit int) ([]models.RophimMovie, error) {
url := fmt.Sprintf("%s/tim-kiem?keyword=%s", BaseURL, query) url := fmt.Sprintf("%s/tim-kiem?keyword=%s", BaseURL, query)
doc, err := s.fetchDocument(url) doc, err := s.fetchDocument(url)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.parseMovieGrid(doc, limit), nil return s.parseMovieGrid(doc, limit), nil
} }
func (s *RophimScraper) parseMovieGrid(doc *goquery.Document, limit int) []models.RophimMovie { func (s *RophimScraper) parseMovieGrid(doc *goquery.Document, limit int) []models.RophimMovie {
var movies []models.RophimMovie var movies []models.RophimMovie
doc.Find(".myui-vodlist__box").EachWithBreak(func(i int, s *goquery.Selection) bool { doc.Find(".myui-vodlist__box").EachWithBreak(func(i int, s *goquery.Selection) bool {
if i >= limit { if i >= limit {
return false return false
} }
link := s.Find("a.myui-vodlist__thumb") link := s.Find("a.myui-vodlist__thumb")
if link.Length() == 0 { if link.Length() == 0 {
link = s.Find("a[href*='/phim/']") link = s.Find("a[href*='/phim/']")
} }
if link.Length() == 0 { if link.Length() == 0 {
return true return true
} }
href, _ := link.Attr("href") href, _ := link.Attr("href")
slug := extractSlug(href) slug := extractSlug(href)
if slug == "" { if slug == "" {
return true return true
} }
title, _ := link.Attr("title") title, _ := link.Attr("title")
if title == "" { if title == "" {
title = s.Find("h4.title a").Text() title = s.Find("h4.title a").Text()
} }
style, _ := link.Attr("style") style, _ := link.Attr("style")
thumbnail := extractThumbnail(style) thumbnail := extractThumbnail(style)
if thumbnail == "" { if thumbnail == "" {
thumbnail, _ = s.Find("img").Attr("src") thumbnail, _ = s.Find("img").Attr("src")
} }
quality := s.Find(".pic-tag").Text() quality := s.Find(".pic-tag").Text()
if quality == "" { if quality == "" {
quality = "HD" quality = "HD"
} }
engTitle := s.Find(".text-muted").Text() engTitle := s.Find(".text-muted").Text()
movie := models.RophimMovie{ movie := models.RophimMovie{
ID: slug, ID: slug,
Title: strings.TrimSpace(title), Title: strings.TrimSpace(title),
OriginalTitle: strings.TrimSpace(engTitle), OriginalTitle: strings.TrimSpace(engTitle),
Slug: slug, Slug: slug,
Thumbnail: normalizeURL(thumbnail), Thumbnail: normalizeURL(thumbnail),
Quality: strings.TrimSpace(quality), Quality: strings.TrimSpace(quality),
Category: "movies", // Default Category: "movies", // Default
} }
movies = append(movies, movie) movies = append(movies, movie)
return true return true
}) })
return movies return movies
} }
func (s *RophimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) { func (s *RophimScraper) GetMovieDetail(slug string) (*models.RophimMovie, error) {
url := fmt.Sprintf("%s/phim/%s", BaseURL, slug) url := fmt.Sprintf("%s/phim/%s", BaseURL, slug)
doc, err := s.fetchDocument(url) doc, err := s.fetchDocument(url)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.parseMovieDetail(doc, slug), nil return s.parseMovieDetail(doc, slug), nil
} }
func (s *RophimScraper) parseMovieDetail(doc *goquery.Document, slug string) *models.RophimMovie { func (s *RophimScraper) parseMovieDetail(doc *goquery.Document, slug string) *models.RophimMovie {
title := doc.Find("h1.movie-title").Text() title := doc.Find("h1.movie-title").Text()
if title == "" { if title == "" {
title = doc.Find("h1").Text() title = doc.Find("h1").Text()
} }
description := doc.Find("meta[name='description']").AttrOr("content", "") description := doc.Find("meta[name='description']").AttrOr("content", "")
if description == "" { if description == "" {
description = doc.Find(".description, .content, .film-description").Text() description = doc.Find(".description, .content, .film-description").Text()
} }
poster := doc.Find("meta[property='og:image']").AttrOr("content", "") poster := doc.Find("meta[property='og:image']").AttrOr("content", "")
// Parse Info (Year, Country, etc) - simplified for brevity // Parse Info (Year, Country, etc) - simplified for brevity
var year int var year int
doc.Find(".movie-info li, .film-info li").Each(func(i int, s *goquery.Selection) { doc.Find(".movie-info li, .film-info li").Each(func(i int, s *goquery.Selection) {
text := s.Text() text := s.Text()
if strings.Contains(text, "Năm") || strings.Contains(text, "Year") { if strings.Contains(text, "Năm") || strings.Contains(text, "Year") {
re := regexp.MustCompile(`\d{4}`) re := regexp.MustCompile(`\d{4}`)
if match := re.FindString(text); match != "" { if match := re.FindString(text); match != "" {
year, _ = strconv.Atoi(match) year, _ = strconv.Atoi(match)
} }
} }
}) })
// Parse Episodes // Parse Episodes
var episodes []models.Episode var episodes []models.Episode
doc.Find("a[href*='/tap-'], a[href*='episode'], .episode-list a").Each(func(i int, s *goquery.Selection) { doc.Find("a[href*='/tap-'], a[href*='episode'], .episode-list a").Each(func(i int, s *goquery.Selection) {
href, _ := s.Attr("href") href, _ := s.Attr("href")
text := strings.TrimSpace(s.Text()) text := strings.TrimSpace(s.Text())
re := regexp.MustCompile(`tap-(\d+)`) re := regexp.MustCompile(`tap-(\d+)`)
match := re.FindStringSubmatch(href) match := re.FindStringSubmatch(href)
if len(match) > 1 { if len(match) > 1 {
epNum, _ := strconv.Atoi(match[1]) epNum, _ := strconv.Atoi(match[1])
episodes = append(episodes, models.Episode{ episodes = append(episodes, models.Episode{
Number: epNum, Number: epNum,
Title: text, Title: text,
URL: normalizeURL(href), URL: normalizeURL(href),
}) })
} }
}) })
// De-duplicate episodes // De-duplicate episodes
seen := make(map[int]bool) seen := make(map[int]bool)
var uniqueEpisodes []models.Episode var uniqueEpisodes []models.Episode
for _, ep := range episodes { for _, ep := range episodes {
if !seen[ep.Number] { if !seen[ep.Number] {
seen[ep.Number] = true seen[ep.Number] = true
uniqueEpisodes = append(uniqueEpisodes, ep) uniqueEpisodes = append(uniqueEpisodes, ep)
} }
} }
return &models.RophimMovie{ return &models.RophimMovie{
ID: slug, ID: slug,
Title: strings.TrimSpace(title), Title: strings.TrimSpace(title),
Slug: slug, Slug: slug,
Thumbnail: normalizeURL(poster), Thumbnail: normalizeURL(poster),
Description: strings.TrimSpace(description), Description: strings.TrimSpace(description),
Year: year, Year: year,
Episodes: uniqueEpisodes, Episodes: uniqueEpisodes,
Category: "movies", Category: "movies",
} }
} }
func extractSlug(url string) string { func extractSlug(url string) string {
re := regexp.MustCompile(`/phim/([^/?#]+)`) re := regexp.MustCompile(`/phim/([^/?#]+)`)
matches := re.FindStringSubmatch(url) matches := re.FindStringSubmatch(url)
if len(matches) > 1 { if len(matches) > 1 {
return matches[1] return matches[1]
} }
// Fallback // Fallback
parts := strings.Split(url, "/") parts := strings.Split(url, "/")
if len(parts) > 0 { if len(parts) > 0 {
return parts[len(parts)-1] return parts[len(parts)-1]
} }
return "" return ""
} }
func extractThumbnail(style string) string { func extractThumbnail(style string) string {
re := regexp.MustCompile(`url\(([^)]+)\)`) re := regexp.MustCompile(`url\(([^)]+)\)`)
matches := re.FindStringSubmatch(style) matches := re.FindStringSubmatch(style)
if len(matches) > 1 { if len(matches) > 1 {
return strings.Trim(matches[1], "'\"") return strings.Trim(matches[1], "'\"")
} }
return "" return ""
} }
func normalizeURL(url string) string { func normalizeURL(url string) string {
if url == "" { if url == "" {
return "" return ""
} }
if strings.HasPrefix(url, "//") { if strings.HasPrefix(url, "//") {
return "https:" + url return "https:" + url
} }
if strings.HasPrefix(url, "/") { if strings.HasPrefix(url, "/") {
return BaseURL + url return BaseURL + url
} }
return url return url
} }

View file

@ -1,96 +1,127 @@
package service package service
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "net/http"
"os/exec" "os"
"path/filepath" "os/exec"
"strings" "path/filepath"
"time" "strings"
) "time"
type VideoInfo struct { "github.com/PuerkitoBio/goquery"
Title string `json:"title"` )
Thumbnail string `json:"thumbnail"`
Duration int `json:"duration"` type VideoInfo struct {
StreamURL string `json:"url"` // yt-dlp JSON key is 'url' Title string `json:"title"`
FormatID string `json:"format_id"` Thumbnail string `json:"thumbnail"`
Resolution string `json:"resolution"` // Custom field Duration int `json:"duration"`
Ext string `json:"ext"` StreamURL string `json:"url"` // yt-dlp JSON key is 'url'
} FormatID string `json:"format_id"`
Resolution string `json:"resolution"` // Custom field
type VideoExtractor struct{} Ext string `json:"ext"`
}
func NewVideoExtractor() *VideoExtractor {
return &VideoExtractor{} type VideoExtractor struct{}
}
func NewVideoExtractor() *VideoExtractor {
func (e *VideoExtractor) Extract(url string, quality string) (*VideoInfo, error) { return &VideoExtractor{}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) }
defer cancel()
func (e *VideoExtractor) Extract(url string, quality string) (*VideoInfo, error) {
// Check for custom extractors ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
if strings.Contains(url, "phim30.me") { defer cancel()
// Currently returning the URL as-is, letting yt-dlp attempt extraction
// or allowing the frontend iframe to handle it directly if it's embeddable // Check for custom extractors
} if strings.Contains(url, "phim30.me") {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
// Build format selector if err != nil {
formatSelector := "bestvideo+bestaudio/best" return nil, fmt.Errorf("failed to create phim30 request: %v", err)
if quality != "" { }
height := strings.Replace(quality, "p", "", -1) req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
formatSelector = fmt.Sprintf("bestvideo[height<=%s]+bestaudio/best[height<=%s]/best", height, height)
} client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
args := []string{ if err != nil {
"--dump-json", return nil, fmt.Errorf("failed to fetch phim30 page: %v", err)
"--no-playlist", }
"--no-warnings", defer resp.Body.Close()
"--format", formatSelector,
url, if resp.StatusCode != http.StatusOK {
} return nil, fmt.Errorf("phim30 returned status: %d", resp.StatusCode)
}
// Check for local yt-dlp.exe
ytDlpCmd := "yt-dlp" doc, err := goquery.NewDocumentFromReader(resp.Body)
// Only on windows for simplicity or check OS if err != nil {
if _, err := os.Stat("yt-dlp.exe"); err == nil { return nil, fmt.Errorf("failed to parse phim30 page: %v", err)
path, _ := filepath.Abs("yt-dlp.exe") }
ytDlpCmd = path
} streamURL, _ := doc.Find("[data-movie-player-src-value]").Attr("data-movie-player-src-value")
if streamURL != "" {
cmd := exec.CommandContext(ctx, ytDlpCmd, args...) return &VideoInfo{
output, err := cmd.Output() StreamURL: streamURL,
if err != nil { Resolution: "unknown",
return nil, fmt.Errorf("extraction failed: %v", err) }, nil
} }
return nil, fmt.Errorf("could not find stream URL on phim30 page")
var info VideoInfo }
// yt-dlp dumps JSON. Unmarshal it.
// Note: yt-dlp JSON has many fields, we only map the ones in VideoInfo struct // Build format selector
if err := json.Unmarshal(output, &info); err != nil { formatSelector := "bestvideo+bestaudio/best"
return nil, fmt.Errorf("json parse error: %v", err) if quality != "" {
} height := strings.Replace(quality, "p", "", -1)
formatSelector = fmt.Sprintf("bestvideo[height<=%s]+bestaudio/best[height<=%s]/best", height, height)
// Post-process resolution if not directly available or custom logic needed }
// In strict parsing, we might need a custom struct to catch 'height' and 'width' to form resolution
// allowing dynamic map parsing for simplicity: args := []string{
var rawData map[string]interface{} "--dump-json",
json.Unmarshal(output, &rawData) "--no-playlist",
"--no-warnings",
if h, ok := rawData["height"].(float64); ok { "--format", formatSelector,
info.Resolution = fmt.Sprintf("%dp", int(h)) url,
} else { }
info.Resolution = "unknown"
} // Check for local yt-dlp.exe
ytDlpCmd := "yt-dlp"
// Ensure StreamURL is populated (sometimes 'url' is the stream url) // Only on windows for simplicity or check OS
if info.StreamURL == "" { if _, err := os.Stat("yt-dlp.exe"); err == nil {
if u, ok := rawData["url"].(string); ok { path, _ := filepath.Abs("yt-dlp.exe")
info.StreamURL = u ytDlpCmd = path
} }
}
cmd := exec.CommandContext(ctx, ytDlpCmd, args...)
return &info, nil output, err := cmd.Output()
} if err != nil {
return nil, fmt.Errorf("extraction failed: %v", err)
}
var info VideoInfo
// yt-dlp dumps JSON. Unmarshal it.
// Note: yt-dlp JSON has many fields, we only map the ones in VideoInfo struct
if err := json.Unmarshal(output, &info); err != nil {
return nil, fmt.Errorf("json parse error: %v", err)
}
// Post-process resolution if not directly available or custom logic needed
// In strict parsing, we might need a custom struct to catch 'height' and 'width' to form resolution
// allowing dynamic map parsing for simplicity:
var rawData map[string]interface{}
json.Unmarshal(output, &rawData)
if h, ok := rawData["height"].(float64); ok {
info.Resolution = fmt.Sprintf("%dp", int(h))
} else {
info.Resolution = "unknown"
}
// Ensure StreamURL is populated (sometimes 'url' is the stream url)
if info.StreamURL == "" {
if u, ok := rawData["url"].(string); ok {
info.StreamURL = u
}
}
return &info, nil
}

View file

@ -1,113 +1,116 @@
package service package service
import ( import (
"bytes" "bytes"
"crypto/md5" "crypto/md5"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"image" "image"
"image/jpeg" "image/jpeg"
"image/png" "image/png"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"golang.org/x/image/draw" "golang.org/x/image/draw"
) )
const CacheDir = "cache/images" const CacheDir = "cache/images"
type ImageService struct { type ImageService struct {
client *http.Client client *http.Client
} }
func NewImageService() *ImageService { func NewImageService() *ImageService {
os.MkdirAll(CacheDir, 0755) os.MkdirAll(CacheDir, 0755)
// Use custom transport to skip SSL verification // Use custom transport to skip SSL verification
tr := &http.Transport{ tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
} }
return &ImageService{ return &ImageService{
client: &http.Client{ client: &http.Client{
Transport: tr, Transport: tr,
Timeout: 15 * time.Second, Timeout: 15 * time.Second,
}, },
} }
} }
func (s *ImageService) GetProxiedImage(url string, width int) ([]byte, string, error) { func (s *ImageService) GetProxiedImage(url string, width int) ([]byte, string, error) {
hash := md5.Sum([]byte(fmt.Sprintf("%s_%d", url, width))) hash := md5.Sum([]byte(fmt.Sprintf("%s_%d", url, width)))
cacheKey := fmt.Sprintf("%x.jpg", hash) cacheKey := fmt.Sprintf("%x.jpg", hash)
cachePath := filepath.Join(CacheDir, cacheKey) cachePath := filepath.Join(CacheDir, cacheKey)
// Check cache // Check cache
if _, err := os.Stat(cachePath); err == nil { if _, err := os.Stat(cachePath); err == nil {
data, err := os.ReadFile(cachePath) data, err := os.ReadFile(cachePath)
if err == nil { if err == nil {
return data, "image/jpeg", nil return data, "image/jpeg", nil
} }
} }
// Fetch with custom request to set headers // Fetch with custom request to set headers
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Referer", "https://ophim1.com/") req.Header.Set("Referer", "https://ophim1.com/")
resp, err := s.client.Do(req) resp, err := s.client.Do(req)
if err != nil { if err != nil {
return nil, "", err fmt.Printf("GetProxiedImage fetch error: %v\n", err)
} return nil, "", err
defer resp.Body.Close() }
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, "", fmt.Errorf("image fetch failed: %d", resp.StatusCode) if resp.StatusCode != 200 {
} fmt.Printf("GetProxiedImage status error: %d for url: %s\n", resp.StatusCode, url)
return nil, "", fmt.Errorf("image fetch failed: %d", resp.StatusCode)
// Decode }
var img image.Image
contentType := resp.Header.Get("Content-Type") // Decode
var img image.Image
switch contentType { contentType := resp.Header.Get("Content-Type")
case "image/jpeg":
img, err = jpeg.Decode(resp.Body) switch contentType {
case "image/png": case "image/jpeg":
img, err = png.Decode(resp.Body) img, err = jpeg.Decode(resp.Body)
default: case "image/png":
// Attempt agnostic decode img, err = png.Decode(resp.Body)
img, _, err = image.Decode(resp.Body) default:
} // Attempt agnostic decode
img, _, err = image.Decode(resp.Body)
if err != nil { }
return nil, "", fmt.Errorf("decode error: %v", err)
} if err != nil {
fmt.Printf("GetProxiedImage decode error: %v for content-type: %s and url: %s\n", err, contentType, url)
// Resize if needed return nil, "", fmt.Errorf("decode error: %v", err)
if width > 0 && img.Bounds().Dx() > width { }
bounds := img.Bounds()
ratio := float64(width) / float64(bounds.Dx()) // Resize if needed
height := int(float64(bounds.Dy()) * ratio) if width > 0 && img.Bounds().Dx() > width {
bounds := img.Bounds()
dst := image.NewRGBA(image.Rect(0, 0, width, height)) ratio := float64(width) / float64(bounds.Dx())
draw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil) height := int(float64(bounds.Dy()) * ratio)
img = dst
} dst := image.NewRGBA(image.Rect(0, 0, width, height))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil)
// Encode to JPEG img = dst
var buf bytes.Buffer }
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
return nil, "", fmt.Errorf("jpeg encode error: %v", err) // Encode to JPEG
} var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
jpegData := buf.Bytes() return nil, "", fmt.Errorf("jpeg encode error: %v", err)
}
// Write cache
os.WriteFile(cachePath, jpegData, 0644) jpegData := buf.Bytes()
return jpegData, "image/jpeg", nil // Write cache
} os.WriteFile(cachePath, jpegData, 0644)
return jpegData, "image/jpeg", nil
}

View file

@ -1,137 +1,137 @@
package service package service
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"time" "time"
) )
const ( const (
TMDBBaseURL = "https://api.themoviedb.org/3" TMDBBaseURL = "https://api.themoviedb.org/3"
TMDBImageBaseURL = "https://image.tmdb.org/t/p" TMDBImageBaseURL = "https://image.tmdb.org/t/p"
) )
type TMDBService struct { type TMDBService struct {
client *http.Client client *http.Client
apiKey string apiKey string
} }
func NewTMDBService() *TMDBService { func NewTMDBService() *TMDBService {
return &TMDBService{ return &TMDBService{
client: &http.Client{Timeout: 10 * time.Second}, client: &http.Client{Timeout: 10 * time.Second},
apiKey: os.Getenv("TMDB_API_KEY"), apiKey: os.Getenv("TMDB_API_KEY"),
} }
} }
type TMDBMovieResult struct { type TMDBMovieResult struct {
ID int `json:"id"` ID int `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Overview string `json:"overview"` Overview string `json:"overview"`
PosterPath string `json:"poster_path"` PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"` BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
VoteAverage float64 `json:"vote_average"` VoteAverage float64 `json:"vote_average"`
} }
type TMDBSearchResponse struct { type TMDBSearchResponse struct {
Results []TMDBMovieResult `json:"results"` Results []TMDBMovieResult `json:"results"`
} }
type TMDBMovieDetails struct { type TMDBMovieDetails struct {
ID int `json:"id"` ID int `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Overview string `json:"overview"` Overview string `json:"overview"`
Runtime int `json:"runtime"` Runtime int `json:"runtime"`
Budget int64 `json:"budget"` Budget int64 `json:"budget"`
Revenue int64 `json:"revenue"` Revenue int64 `json:"revenue"`
Tagline string `json:"tagline"` Tagline string `json:"tagline"`
VoteAverage float64 `json:"vote_average"` VoteAverage float64 `json:"vote_average"`
PosterPath string `json:"poster_path"` PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"` BackdropPath string `json:"backdrop_path"`
Credits struct { Credits struct {
Cast []struct { Cast []struct {
Name string `json:"name"` Name string `json:"name"`
Character string `json:"character"` Character string `json:"character"`
ProfilePath string `json:"profile_path"` ProfilePath string `json:"profile_path"`
} `json:"cast"` } `json:"cast"`
Crew []struct { Crew []struct {
Name string `json:"name"` Name string `json:"name"`
Job string `json:"job"` Job string `json:"job"`
} `json:"crew"` } `json:"crew"`
} `json:"credits"` } `json:"credits"`
} }
func (s *TMDBService) SearchMovie(title string, year int) (*TMDBMovieResult, error) { func (s *TMDBService) SearchMovie(title string, year int) (*TMDBMovieResult, error) {
if s.apiKey == "" { if s.apiKey == "" {
return nil, fmt.Errorf("TMDB_API_KEY not set") return nil, fmt.Errorf("TMDB_API_KEY not set")
} }
params := url.Values{} params := url.Values{}
params.Add("api_key", s.apiKey) params.Add("api_key", s.apiKey)
params.Add("query", title) params.Add("query", title)
params.Add("language", "en-US") params.Add("language", "en-US")
if year > 0 { if year > 0 {
params.Add("year", fmt.Sprintf("%d", year)) params.Add("year", fmt.Sprintf("%d", year))
} }
resp, err := s.client.Get(fmt.Sprintf("%s/search/movie?%s", TMDBBaseURL, params.Encode())) resp, err := s.client.Get(fmt.Sprintf("%s/search/movie?%s", TMDBBaseURL, params.Encode()))
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode) return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode)
} }
var searchResp TMDBSearchResponse var searchResp TMDBSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, err return nil, err
} }
if len(searchResp.Results) > 0 { if len(searchResp.Results) > 0 {
return &searchResp.Results[0], nil return &searchResp.Results[0], nil
} }
return nil, nil return nil, nil
} }
func (s *TMDBService) GetMovieDetails(tmdbID int) (*TMDBMovieDetails, error) { func (s *TMDBService) GetMovieDetails(tmdbID int) (*TMDBMovieDetails, error) {
if s.apiKey == "" { if s.apiKey == "" {
return nil, fmt.Errorf("TMDB_API_KEY not set") return nil, fmt.Errorf("TMDB_API_KEY not set")
} }
params := url.Values{} params := url.Values{}
params.Add("api_key", s.apiKey) params.Add("api_key", s.apiKey)
params.Add("append_to_response", "credits") params.Add("append_to_response", "credits")
params.Add("language", "en-US") params.Add("language", "en-US")
resp, err := s.client.Get(fmt.Sprintf("%s/movie/%d?%s", TMDBBaseURL, tmdbID, params.Encode())) resp, err := s.client.Get(fmt.Sprintf("%s/movie/%d?%s", TMDBBaseURL, tmdbID, params.Encode()))
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode) return nil, fmt.Errorf("TMDB API returned status: %d", resp.StatusCode)
} }
var details TMDBMovieDetails var details TMDBMovieDetails
if err := json.NewDecoder(resp.Body).Decode(&details); err != nil { if err := json.NewDecoder(resp.Body).Decode(&details); err != nil {
return nil, err return nil, err
} }
return &details, nil return &details, nil
} }
func (s *TMDBService) GetPosterURL(path string, size string) string { func (s *TMDBService) GetPosterURL(path string, size string) string {
if path == "" { if path == "" {
return "" return ""
} }
if size == "" { if size == "" {
size = "w500" size = "w500"
} }
return fmt.Sprintf("%s/%s%s", TMDBImageBaseURL, size, path) return fmt.Sprintf("%s/%s%s", TMDBImageBaseURL, size, path)
} }

View file

@ -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

View file

@ -2,7 +2,7 @@ version: '3.8'
services: services:
streamflow: streamflow:
image: git.khoavo.myds.me/vndangkhoa/kv-streamflow:v3.9 image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v6
container_name: streamflow container_name: streamflow
platform: linux/amd64 platform: linux/amd64
ports: ports:
@ -12,10 +12,11 @@ services:
- PORT=8000 - PORT=8000
- TZ=Asia/Ho_Chi_Minh - TZ=Asia/Ho_Chi_Minh
volumes: volumes:
# Synology: Use relative path for data persistence
- ./data:/app/data - ./data:/app/data
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: [ "CMD", "wget", "-q", "--spider", "http://localhost:8000/api/health" ] test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/api/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
export default { export default {
plugins: { plugins: {
'@tailwindcss/postcss': {}, '@tailwindcss/postcss': {},
autoprefixer: {}, autoprefixer: {},
}, },
} }

Some files were not shown because too many files have changed in this diff Show more