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

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

BIN
android-tv/adb_logs.txt Normal file

Binary file not shown.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

188
android-tv/gradlew.bat vendored
View file

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

46385
android-tv/logcat.txt Normal file

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 {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "StreamFlowTV"
include(":app")
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "StreamFlowTV"
include(":app")

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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 {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

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