feat: téléchargements in-app Android (débrideur + page /downloads)

Ajoute un système de téléchargement natif Android complet :

Android natif :
- DownloadEntry, DownloadStore (SharedPreferences), DownloadJob (OkHttp + Range header pour pause/reprise), DownloadService (ForegroundService, 3 workers parallèles), DownloadEventBus, DownloadModule (bridge RN)
- FileProvider launch : ouvre le fichier téléchargé dans le lecteur vidéo du téléphone
- Permissions FOREGROUND_SERVICE_DATA_SYNC + service déclaré dans AndroidManifest
- Dépendance OkHttp 4.12.0 dans build.gradle

React Native :
- bridge-runtime.ts : window.MovixBridge injecté avant chargement de page (start/pause/resume/cancel/delete/launch/list/get/subscribe)
- downloadBridge.ts : routage des messages MOVIX_DOWNLOAD_* vers NativeModules
- BrowserScreen : startDownloadEventForwarding pour push temps réel vers WebView
- bridge.ts : routage vers downloadBridge

Web frontend :
- appBridge.ts : types MovixBridge, helpers isMovixApp/getMovixBridge/buildDownloadSubFolder
- DebridPage : bouton « Télécharger dans l'application » (Android uniquement)
- DownloadPage : propagation des métadonnées (type/tmdb/titre/poster/saison/épisode) vers /debrid
- DownloadsPage : liste groupée Films/Séries/Animés, barre de progression, vitesse, ETA, pause/reprise/annuler, bouton Lancer (done), confirmation suppression inline
- Header : icône téléchargements avec badge actifs (Android uniquement)
- Routing : route /downloads
- i18n FR + EN : clés downloads.* + debrid.appDownload*

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
theo 2026-05-17 03:31:03 +02:00
parent 2045dbd2c9
commit 7a51ec71d9
24 changed files with 2101 additions and 6 deletions

View file

@ -70,6 +70,11 @@ dependencies {
// Google Cast (Chromecast) see app/src/main/java/com/movix/app/cast/
implementation("com.google.android.gms:play-services-cast-framework:21.5.0")
implementation("androidx.mediarouter:mediarouter:1.7.0")
// Téléchargements custom see app/src/main/java/com/movix/app/download/
// okhttp est déjà fourni transitivement par react-android mais on l'épingle
// pour garantir l'API utilisée par DownloadJob (Range / RandomAccessFile).
implementation("com.squareup.okhttp3:okhttp:4.12.0")
}
// androidx.legacy:legacy-support-core-utils:1.0.0 (tiré transitivement par

View file

@ -5,6 +5,9 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Foreground service pour les téléchargements in-app initiés depuis le débrideur -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application
android:name=".MainApplication"
@ -49,6 +52,12 @@
</intent-filter>
</service>
<!-- Foreground service qui héberge les téléchargements custom (OkHttp + Range) -->
<service
android:name=".download.DownloadService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.updateprovider"

View file

@ -8,6 +8,7 @@ import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.soloader.SoLoader
import com.movix.app.CastPackage
import com.movix.app.download.DownloadPackage
import com.movix.app.update.UpdatePackage
class MainApplication : Application(), ReactApplication {
@ -19,6 +20,7 @@ class MainApplication : Application(), ReactApplication {
add(DnsPackage())
add(UpdatePackage())
add(CastPackage())
add(DownloadPackage())
}
override fun getJSMainModuleName(): String = "index"

View file

@ -0,0 +1,71 @@
package com.movix.app.download
import org.json.JSONObject
/**
* Snapshot persistant d'un téléchargement géré par le module.
*
* `metadataJson` est opaque pour le code natif : la couche JS y stocke ce
* qu'elle veut (type, tmdbId, titre, poster, saison, épisode, ...) et le
* relit pour reconstruire la page Downloads.
*
* `headersJson` est une JSON map<string, string> de headers HTTP à envoyer
* sur la requête (auth, referer, etc.).
*/
data class DownloadEntry(
val id: String,
val url: String,
val filename: String,
val targetPath: String,
var totalBytes: Long,
var downloadedBytes: Long,
var status: String,
var errorMessage: String?,
val createdAt: Long,
var updatedAt: Long,
val metadataJson: String,
val headersJson: String,
) {
fun toJson(): JSONObject {
return JSONObject().apply {
put("id", id)
put("url", url)
put("filename", filename)
put("targetPath", targetPath)
put("totalBytes", totalBytes)
put("downloadedBytes", downloadedBytes)
put("status", status)
put("errorMessage", errorMessage ?: JSONObject.NULL)
put("createdAt", createdAt)
put("updatedAt", updatedAt)
put("metadata", if (metadataJson.isBlank()) JSONObject() else JSONObject(metadataJson))
put("headers", if (headersJson.isBlank()) JSONObject() else JSONObject(headersJson))
}
}
companion object {
const val STATUS_QUEUED = "queued"
const val STATUS_RUNNING = "running"
const val STATUS_PAUSED = "paused"
const val STATUS_DONE = "done"
const val STATUS_FAILED = "failed"
const val STATUS_CANCELLED = "cancelled"
fun fromJson(obj: JSONObject): DownloadEntry {
return DownloadEntry(
id = obj.getString("id"),
url = obj.getString("url"),
filename = obj.getString("filename"),
targetPath = obj.getString("targetPath"),
totalBytes = obj.optLong("totalBytes", -1L),
downloadedBytes = obj.optLong("downloadedBytes", 0L),
status = obj.optString("status", STATUS_QUEUED),
errorMessage = if (obj.isNull("errorMessage")) null else obj.optString("errorMessage", null),
createdAt = obj.optLong("createdAt", System.currentTimeMillis()),
updatedAt = obj.optLong("updatedAt", System.currentTimeMillis()),
metadataJson = obj.optJSONObject("metadata")?.toString() ?: "{}",
headersJson = obj.optJSONObject("headers")?.toString() ?: "{}",
)
}
}
}

View file

@ -0,0 +1,35 @@
package com.movix.app.download
import java.util.concurrent.CopyOnWriteArrayList
/**
* Event bus singleton entre `DownloadService` (émetteur) et `DownloadModule`
* (consommateur). On est dans le même process : pas besoin de LocalBroadcastManager.
*/
object DownloadEventBus {
interface Listener {
fun onProgress(id: String, bytesDownloaded: Long, bytesTotal: Long, speedBytesPerSec: Long)
fun onStateChanged(entry: DownloadEntry)
}
private val listeners = CopyOnWriteArrayList<Listener>()
fun subscribe(listener: Listener) {
if (!listeners.contains(listener)) listeners.add(listener)
}
fun unsubscribe(listener: Listener) {
listeners.remove(listener)
}
fun emitProgress(id: String, bytesDownloaded: Long, bytesTotal: Long, speedBytesPerSec: Long) {
listeners.forEach {
runCatching { it.onProgress(id, bytesDownloaded, bytesTotal, speedBytesPerSec) }
}
}
fun emitStateChanged(entry: DownloadEntry) {
listeners.forEach { runCatching { it.onStateChanged(entry) } }
}
}

View file

@ -0,0 +1,212 @@
package com.movix.app.download
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.io.RandomAccessFile
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/**
* Worker qui réalise effectivement le téléchargement HTTP d'une `DownloadEntry`.
*
* Caractéristiques :
* - Range-aware : si le fichier cible existe déjà partiellement et que le serveur
* répond 206 Partial Content, on reprend on s'est arrêté.
* - Pause coopérative : `requestPause()` positionne un flag ; au prochain flush
* de buffer (toutes les ~64KB), le thread arrête proprement, écrit l'état
* `paused` dans le store et émet un event. Le fichier partiel reste.
* - Cancel : même mécanisme, mais on supprime aussi le fichier partiel.
* - Progress throttle : on n'émet pas plus d'un event toutes les ~250ms pour
* éviter de saturer le bridge JS.
*/
class DownloadJob(
private val entry: DownloadEntry,
private val store: DownloadStore,
private val listener: Listener,
private val client: OkHttpClient = defaultClient(),
) : Runnable {
interface Listener {
fun onProgress(entry: DownloadEntry, speedBytesPerSec: Long)
fun onStateChanged(entry: DownloadEntry)
}
private val pauseFlag = AtomicBoolean(false)
private val cancelFlag = AtomicBoolean(false)
fun requestPause() {
pauseFlag.set(true)
}
fun requestCancel() {
cancelFlag.set(true)
}
override fun run() {
try {
// Marque running
entry.status = DownloadEntry.STATUS_RUNNING
entry.errorMessage = null
entry.updatedAt = System.currentTimeMillis()
store.upsert(entry)
listener.onStateChanged(entry)
val target = File(entry.targetPath)
target.parentFile?.mkdirs()
// Resume si fichier déjà partiellement présent
var existingBytes = if (target.exists()) target.length() else 0L
if (existingBytes > 0L && entry.totalBytes in 1..existingBytes) {
// Déjà complet
entry.downloadedBytes = existingBytes
finish(DownloadEntry.STATUS_DONE)
return
}
val builder = Request.Builder().url(entry.url)
parseHeaders(entry.headersJson).forEach { (k, v) ->
builder.addHeader(k, v)
}
if (existingBytes > 0L) {
builder.addHeader("Range", "bytes=$existingBytes-")
}
client.newCall(builder.build()).execute().use { response ->
if (!response.isSuccessful) {
fail("HTTP ${response.code}")
return
}
val body = response.body ?: run {
fail("Empty response body")
return
}
val partialResume = response.code == 206
if (!partialResume && existingBytes > 0L) {
// Serveur ne supporte pas le Range → repartir de zéro
target.delete()
existingBytes = 0L
}
// Total size : Content-Range > Content-Length (+ existing)
val contentLength = body.contentLength()
val total = when {
response.code == 206 -> parseContentRangeTotal(response.header("Content-Range")) ?: -1L
contentLength > 0L -> contentLength + existingBytes
else -> -1L
}
if (total > 0L) {
entry.totalBytes = total
}
entry.downloadedBytes = existingBytes
store.upsert(entry)
RandomAccessFile(target, "rw").use { raf ->
raf.seek(existingBytes)
body.byteStream().use { input ->
val buffer = ByteArray(64 * 1024)
var written = existingBytes
var lastEmit = 0L
var lastBytesAtEmit = written
var lastTimeAtEmit = System.currentTimeMillis()
while (true) {
if (cancelFlag.get()) {
target.delete()
entry.downloadedBytes = 0L
finish(DownloadEntry.STATUS_CANCELLED)
return
}
if (pauseFlag.get()) {
entry.downloadedBytes = written
finish(DownloadEntry.STATUS_PAUSED)
return
}
val read = try {
input.read(buffer)
} catch (e: IOException) {
fail(e.message ?: "Network read error")
return
}
if (read <= 0) break
raf.write(buffer, 0, read)
written += read
entry.downloadedBytes = written
val now = System.currentTimeMillis()
if (now - lastEmit >= 250L) {
lastEmit = now
val elapsed = (now - lastTimeAtEmit).coerceAtLeast(1L)
val speed = ((written - lastBytesAtEmit) * 1000L) / elapsed
lastBytesAtEmit = written
lastTimeAtEmit = now
entry.updatedAt = now
store.upsert(entry)
listener.onProgress(entry, speed)
}
}
}
}
finish(DownloadEntry.STATUS_DONE)
}
} catch (e: Exception) {
fail(e.message ?: e.javaClass.simpleName)
}
}
private fun finish(status: String) {
entry.status = status
entry.updatedAt = System.currentTimeMillis()
store.upsert(entry)
listener.onStateChanged(entry)
}
private fun fail(message: String) {
entry.status = DownloadEntry.STATUS_FAILED
entry.errorMessage = message
entry.updatedAt = System.currentTimeMillis()
store.upsert(entry)
listener.onStateChanged(entry)
}
companion object {
private fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
// Pas de readTimeout : un gros download lent ne doit pas être tué.
.readTimeout(0, TimeUnit.MILLISECONDS)
.writeTimeout(0, TimeUnit.MILLISECONDS)
.callTimeout(0, TimeUnit.MILLISECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build()
private fun parseHeaders(json: String): Map<String, String> {
if (json.isBlank()) return emptyMap()
return try {
val obj = JSONObject(json)
obj.keys().asSequence().associateWith { obj.optString(it, "") }
.filterValues { it.isNotEmpty() }
} catch (_: Exception) {
emptyMap()
}
}
private fun parseContentRangeTotal(header: String?): Long? {
// Content-Range: bytes 1024-2047/4096
if (header == null) return null
val slash = header.lastIndexOf('/')
if (slash < 0) return null
val totalStr = header.substring(slash + 1).trim()
if (totalStr == "*") return null
return totalStr.toLongOrNull()
}
}
}

View file

@ -0,0 +1,357 @@
package com.movix.app.download
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import androidx.core.content.FileProvider
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import org.json.JSONObject
import java.io.File
import java.util.UUID
/**
* Façade RN du système de téléchargement custom.
*
* Surface exposée à JS :
* - `start(opts)` id (string). opts = { url, filename, subFolder?, headers?, metadata? }
* - `pause(id)` / `resume(id)` / `cancel(id)` / `delete(id)`
* - `list()` array d'entries (forme miroir de DownloadEntry.toJson)
* - `get(id)` entry ou null
*
* Events JS :
* - `MovixDownloadProgress` : { id, bytesDownloaded, bytesTotal, speedBytesPerSec }
* - `MovixDownloadState` : entry complète (avec status à jour)
*
* Le module relaie depuis le `DownloadService` via `LocalBroadcastManager`.
*/
class DownloadModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext), LifecycleEventListener {
private val store = DownloadStore(reactContext)
private var subscribed = false
private val busListener = object : DownloadEventBus.Listener {
override fun onProgress(id: String, bytesDownloaded: Long, bytesTotal: Long, speedBytesPerSec: Long) {
val payload = Arguments.createMap().apply {
putString("id", id)
putDouble("bytesDownloaded", bytesDownloaded.toDouble())
putDouble("bytesTotal", bytesTotal.toDouble())
putDouble("speedBytesPerSec", speedBytesPerSec.toDouble())
}
emit("MovixDownloadProgress", payload)
}
override fun onStateChanged(entry: DownloadEntry) {
emit("MovixDownloadState", entryToMap(entry))
}
}
init {
reactContext.addLifecycleEventListener(this)
subscribe()
}
override fun getName(): String = "MovixDownloadModule"
// --- RN methods ------------------------------------------------------
@ReactMethod
fun start(opts: ReadableMap, promise: Promise) {
try {
val url = opts.getString("url") ?: run {
promise.reject("INVALID_URL", "Missing url")
return
}
val parsed = Uri.parse(url)
if (parsed.scheme?.lowercase() !in listOf("http", "https")) {
promise.reject("INVALID_URL", "Only http(s) URLs are allowed")
return
}
val rawFilename = opts.getString("filename")?.takeIf { it.isNotBlank() } ?: "download.bin"
val filename = sanitizeFilename(rawFilename)
val subFolder = opts.getString("subFolder")?.let { sanitizeSubFolder(it) } ?: ""
val dir = reactContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
?: throw IllegalStateException("External files dir unavailable")
val targetDir = if (subFolder.isEmpty()) dir else File(dir, subFolder)
if (!targetDir.exists()) targetDir.mkdirs()
val target = uniquifyTarget(targetDir, filename)
val headersJson = opts.getMap("headers")?.let { readableMapToJson(it) }?.toString() ?: "{}"
val metadataJson = opts.getMap("metadata")?.let { readableMapToJson(it) }?.toString() ?: "{}"
val id = UUID.randomUUID().toString()
val now = System.currentTimeMillis()
val entry = DownloadEntry(
id = id,
url = url,
filename = target.name,
targetPath = target.absolutePath,
totalBytes = -1L,
downloadedBytes = 0L,
status = DownloadEntry.STATUS_QUEUED,
errorMessage = null,
createdAt = now,
updatedAt = now,
metadataJson = metadataJson,
headersJson = headersJson,
)
store.upsert(entry)
startService(DownloadService.ACTION_START, id)
val result = Arguments.createMap().apply {
putString("id", id)
putString("targetPath", target.absolutePath)
}
promise.resolve(result)
} catch (e: Exception) {
promise.reject("START_ERROR", e.message ?: "unknown", e)
}
}
@ReactMethod
fun pause(id: String, promise: Promise) {
startService(DownloadService.ACTION_PAUSE, id)
promise.resolve(null)
}
@ReactMethod
fun resume(id: String, promise: Promise) {
val entry = store.get(id)
if (entry == null) {
promise.reject("NOT_FOUND", "Unknown download id")
return
}
startService(DownloadService.ACTION_RESUME, id)
promise.resolve(null)
}
@ReactMethod
fun cancel(id: String, promise: Promise) {
startService(DownloadService.ACTION_CANCEL, id)
promise.resolve(null)
}
@ReactMethod
fun delete(id: String, promise: Promise) {
val entry = store.get(id)
if (entry == null) {
promise.resolve(null)
return
}
// Annule d'abord (no-op si pas en cours) puis purge fichier + store.
startService(DownloadService.ACTION_CANCEL, id)
try {
File(entry.targetPath).delete()
} catch (_: Exception) { /* best effort */ }
store.remove(id)
promise.resolve(null)
}
@ReactMethod
fun launch(id: String, promise: Promise) {
val entry = store.get(id)
if (entry == null) {
promise.reject("NOT_FOUND", "Download not found")
return
}
if (entry.status != DownloadEntry.STATUS_DONE) {
promise.reject("NOT_DONE", "Download is not complete")
return
}
try {
val file = java.io.File(entry.targetPath)
if (!file.exists()) {
promise.reject("FILE_NOT_FOUND", "File not found on disk")
return
}
val uri = FileProvider.getUriForFile(
reactContext,
"${reactContext.packageName}.updateprovider",
file,
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "video/*")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
reactContext.startActivity(intent)
promise.resolve(null)
} catch (e: Exception) {
promise.reject("LAUNCH_ERROR", e.message ?: "unknown", e)
}
}
@ReactMethod
fun list(promise: Promise) {
try {
val arr: WritableArray = Arguments.createArray()
store.all().forEach { arr.pushMap(entryToMap(it)) }
promise.resolve(arr)
} catch (e: Exception) {
promise.reject("LIST_ERROR", e.message ?: "unknown", e)
}
}
@ReactMethod
fun get(id: String, promise: Promise) {
val entry = store.get(id)
if (entry == null) promise.resolve(null) else promise.resolve(entryToMap(entry))
}
// Requis par NativeEventEmitter pour éviter le warning RN.
@ReactMethod
fun addListener(@Suppress("UNUSED_PARAMETER") eventName: String) { /* no-op */ }
@ReactMethod
fun removeListeners(@Suppress("UNUSED_PARAMETER") count: Int) { /* no-op */ }
// --- Helpers ---------------------------------------------------------
private fun startService(action: String, id: String) {
val intent = Intent(reactContext, DownloadService::class.java).apply {
this.action = action
putExtra(DownloadService.EXTRA_ID, id)
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
reactContext.startForegroundService(intent)
} else {
reactContext.startService(intent)
}
} catch (e: Exception) {
// Si l'app est entièrement en background sur Android 12+, startForegroundService
// peut throw. On laisse l'erreur remonter en log mais on ne crash pas le pont.
android.util.Log.w("MovixDownloadModule", "startService failed for $action", e)
}
}
private fun subscribe() {
if (subscribed) return
DownloadEventBus.subscribe(busListener)
subscribed = true
}
private fun unsubscribe() {
if (!subscribed) return
DownloadEventBus.unsubscribe(busListener)
subscribed = false
}
private fun emit(event: String, payload: WritableMap) {
if (!reactContext.hasActiveReactInstance()) return
try {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(event, payload)
} catch (e: Exception) {
android.util.Log.w("MovixDownloadModule", "emit $event failed", e)
}
}
private fun entryToMap(entry: DownloadEntry): WritableMap {
// Le plus simple : passer par le JSON natif puis convertir.
val json = entry.toJson()
return jsonObjectToWritableMap(json)
}
private fun jsonObjectToWritableMap(obj: JSONObject): WritableMap {
val map = Arguments.createMap()
val keys = obj.keys()
while (keys.hasNext()) {
val key = keys.next()
when (val value = obj.opt(key)) {
null, JSONObject.NULL -> map.putNull(key)
is Boolean -> map.putBoolean(key, value)
is Int -> map.putInt(key, value)
is Long -> map.putDouble(key, value.toDouble())
is Double -> map.putDouble(key, value)
is String -> map.putString(key, value)
is JSONObject -> map.putMap(key, jsonObjectToWritableMap(value))
else -> map.putString(key, value.toString())
}
}
return map
}
private fun readableMapToJson(map: ReadableMap): JSONObject {
val obj = JSONObject()
val iter = map.keySetIterator()
while (iter.hasNextKey()) {
val key = iter.nextKey()
when (map.getType(key)) {
com.facebook.react.bridge.ReadableType.Null -> obj.put(key, JSONObject.NULL)
com.facebook.react.bridge.ReadableType.Boolean -> obj.put(key, map.getBoolean(key))
com.facebook.react.bridge.ReadableType.Number -> obj.put(key, map.getDouble(key))
com.facebook.react.bridge.ReadableType.String -> obj.put(key, map.getString(key))
com.facebook.react.bridge.ReadableType.Map -> obj.put(key, readableMapToJson(map.getMap(key)!!))
com.facebook.react.bridge.ReadableType.Array -> obj.put(key, readableArrayToJson(map.getArray(key)!!))
}
}
return obj
}
private fun readableArrayToJson(arr: ReadableArray): org.json.JSONArray {
val out = org.json.JSONArray()
for (i in 0 until arr.size()) {
when (arr.getType(i)) {
com.facebook.react.bridge.ReadableType.Null -> out.put(JSONObject.NULL)
com.facebook.react.bridge.ReadableType.Boolean -> out.put(arr.getBoolean(i))
com.facebook.react.bridge.ReadableType.Number -> out.put(arr.getDouble(i))
com.facebook.react.bridge.ReadableType.String -> out.put(arr.getString(i))
com.facebook.react.bridge.ReadableType.Map -> out.put(readableMapToJson(arr.getMap(i)))
com.facebook.react.bridge.ReadableType.Array -> out.put(readableArrayToJson(arr.getArray(i)))
}
}
return out
}
private fun sanitizeFilename(name: String): String {
// Retire caractères interdits FAT/ext4, garde uniquement le basename.
val basename = name.substringAfterLast('/').substringAfterLast('\\')
val cleaned = basename.replace(Regex("[\\\\/:*?\"<>|\\u0000-\\u001f]"), "_").trim()
return cleaned.ifBlank { "download.bin" }.take(180)
}
private fun sanitizeSubFolder(path: String): String {
// Empêche le path traversal. Garde les segments alphanum + tiret/underscore.
return path.split('/', '\\')
.map { it.trim() }
.filter { it.isNotEmpty() && it != "." && it != ".." }
.map { it.replace(Regex("[^A-Za-z0-9._-]"), "_").take(80) }
.joinToString("/")
}
private fun uniquifyTarget(dir: File, filename: String): File {
val candidate = File(dir, filename)
if (!candidate.exists()) return candidate
val dot = filename.lastIndexOf('.')
val stem = if (dot > 0) filename.substring(0, dot) else filename
val ext = if (dot > 0) filename.substring(dot) else ""
var i = 1
while (true) {
val next = File(dir, "$stem ($i)$ext")
if (!next.exists()) return next
i++
}
}
override fun onHostResume() { subscribe() }
override fun onHostPause() { /* listener reste actif pour ne pas rater de progress */ }
override fun onHostDestroy() { unsubscribe() }
}

View file

@ -0,0 +1,16 @@
package com.movix.app.download
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class DownloadPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(DownloadModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}

View file

@ -0,0 +1,181 @@
package com.movix.app.download
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.Future
/**
* Foreground service qui héberge les threads de téléchargement et empêche
* le système d'Android de tuer le process pendant qu'un téléchargement tourne.
*
* Pourquoi un foreground service plutôt qu'un thread libre :
* - Android tue agressivement les apps en background, surtout sur les OEMs
* chinois. Un foreground service avec notification persistante survit.
* - Permet à l'utilisateur de quitter le WebView / l'app sans interrompre le DL.
*
* Communique avec `DownloadModule` via `LocalBroadcastManager` : le service
* ne connaît pas RN, le module relaie vers JS.
*/
class DownloadService : Service() {
private lateinit var executor: ExecutorService
private lateinit var store: DownloadStore
private val jobs = ConcurrentHashMap<String, DownloadJob>()
private val futures = ConcurrentHashMap<String, Future<*>>()
override fun onCreate() {
super.onCreate()
executor = Executors.newFixedThreadPool(MAX_PARALLEL)
store = DownloadStore(this)
createNotificationChannel()
startForeground(NOTIFICATION_ID, buildNotification())
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
val id = intent.getStringExtra(EXTRA_ID) ?: return START_NOT_STICKY
enqueue(id)
}
ACTION_PAUSE -> {
val id = intent.getStringExtra(EXTRA_ID) ?: return START_NOT_STICKY
jobs[id]?.requestPause()
}
ACTION_RESUME -> {
val id = intent.getStringExtra(EXTRA_ID) ?: return START_NOT_STICKY
enqueue(id)
}
ACTION_CANCEL -> {
val id = intent.getStringExtra(EXTRA_ID) ?: return START_NOT_STICKY
val running = jobs[id]
if (running != null) {
running.requestCancel()
} else {
// Pas en cours : on marque directement cancelled dans le store.
val entry = store.get(id)
if (entry != null) {
entry.status = DownloadEntry.STATUS_CANCELLED
entry.updatedAt = System.currentTimeMillis()
store.upsert(entry)
broadcastState(entry)
java.io.File(entry.targetPath).delete()
}
}
}
ACTION_STOP_IF_IDLE -> {
if (jobs.isEmpty()) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
}
return START_STICKY
}
private fun enqueue(id: String) {
if (jobs.containsKey(id)) return // déjà en cours
val entry = store.get(id) ?: return
if (entry.status == DownloadEntry.STATUS_DONE) return
val job = DownloadJob(entry, store, object : DownloadJob.Listener {
override fun onProgress(entry: DownloadEntry, speedBytesPerSec: Long) {
broadcastProgress(entry, speedBytesPerSec)
}
override fun onStateChanged(entry: DownloadEntry) {
broadcastState(entry)
val terminal = entry.status == DownloadEntry.STATUS_DONE ||
entry.status == DownloadEntry.STATUS_FAILED ||
entry.status == DownloadEntry.STATUS_CANCELLED ||
entry.status == DownloadEntry.STATUS_PAUSED
if (terminal) {
jobs.remove(entry.id)
futures.remove(entry.id)
updateNotificationOrStop()
}
}
})
jobs[id] = job
futures[id] = executor.submit(job)
updateNotificationOrStop()
}
private fun updateNotificationOrStop() {
if (jobs.isEmpty()) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return
}
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID, buildNotification())
}
private fun buildNotification(): Notification {
val active = jobs.size
val title = if (active <= 1) "Téléchargement Movix" else "$active téléchargements Movix"
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setContentTitle(title)
.setContentText("Téléchargements en cours dans l'application")
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(
CHANNEL_ID,
"Téléchargements Movix",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Affiche les téléchargements en cours dans l'application Movix."
setShowBadge(false)
}
nm.createNotificationChannel(channel)
}
}
private fun broadcastProgress(entry: DownloadEntry, speedBytesPerSec: Long) {
DownloadEventBus.emitProgress(entry.id, entry.downloadedBytes, entry.totalBytes, speedBytesPerSec)
}
private fun broadcastState(entry: DownloadEntry) {
DownloadEventBus.emitStateChanged(entry)
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
jobs.values.forEach { it.requestCancel() }
executor.shutdownNow()
}
companion object {
private const val MAX_PARALLEL = 3
private const val CHANNEL_ID = "movix_downloads"
private const val NOTIFICATION_ID = 4242
const val ACTION_START = "com.movix.app.download.START"
const val ACTION_PAUSE = "com.movix.app.download.PAUSE"
const val ACTION_RESUME = "com.movix.app.download.RESUME"
const val ACTION_CANCEL = "com.movix.app.download.CANCEL"
const val ACTION_STOP_IF_IDLE = "com.movix.app.download.STOP_IF_IDLE"
const val EXTRA_ID = "id"
}
}

View file

@ -0,0 +1,61 @@
package com.movix.app.download
import android.content.Context
import android.content.SharedPreferences
import org.json.JSONArray
import org.json.JSONObject
/**
* Persistance des entries via SharedPreferences. Source de vérité native :
* la liste des téléchargements survit aux redémarrages du process et à un
* rechargement complet du WebView.
*
* La couche JS conserve sa propre copie pour l'affichage immédiat ; elle
* resync via `list()` à l'ouverture de la page Downloads.
*/
class DownloadStore(context: Context) {
private val prefs: SharedPreferences = context.applicationContext
.getSharedPreferences("movix_downloads", Context.MODE_PRIVATE)
@Synchronized
fun all(): List<DownloadEntry> {
val raw = prefs.getString(KEY_ENTRIES, null) ?: return emptyList()
return try {
val arr = JSONArray(raw)
(0 until arr.length()).mapNotNull { i ->
runCatching { DownloadEntry.fromJson(arr.getJSONObject(i)) }.getOrNull()
}
} catch (_: Exception) {
emptyList()
}
}
@Synchronized
fun get(id: String): DownloadEntry? = all().firstOrNull { it.id == id }
@Synchronized
fun upsert(entry: DownloadEntry) {
val list = all().toMutableList()
val idx = list.indexOfFirst { it.id == entry.id }
if (idx >= 0) list[idx] = entry else list.add(entry)
persist(list)
}
@Synchronized
fun remove(id: String) {
val list = all().toMutableList()
list.removeAll { it.id == id }
persist(list)
}
private fun persist(list: List<DownloadEntry>) {
val arr = JSONArray()
list.forEach { arr.put(it.toJson()) }
prefs.edit().putString(KEY_ENTRIES, arr.toString()).apply()
}
companion object {
private const val KEY_ENTRIES = "entries"
}
}

View file

@ -1,4 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m
android.useAndroidX=true
android.enableJetifier=true
# Évite la régénération transitive des R classes (chaque lib recompilait

0
app/android/gradlew vendored Normal file → Executable file
View file

View file

@ -195,6 +195,84 @@ export function buildBridgeRuntime(): string {
// unsafeWindow = window (pas de sandboxing dans le WebView)
window.unsafeWindow = window;
// --- Bridge des téléchargements custom (Android only) ----------------
// Expose window.MovixBridge avec :
// isApp: true — sentinelle de détection app
// platform: 'android' — info plateforme
// download: { — API des téléchargements in-app
// start, pause, resume, cancel, delete, list, get,
// subscribe(cb) → unsubscribe
// }
//
// Toutes les méthodes retournent une Promise. Le bridge utilise un canal
// séparé (MOVIX_DOWNLOAD_* / __MOVIX_DOWNLOAD_RESPONSE) du canal GM_*.
var _downloadPending = {};
var _downloadCounter = 0;
function _downloadGenerateId() {
return 'dl_' + (++_downloadCounter) + '_' + Date.now();
}
window.addEventListener('__MOVIX_DOWNLOAD_RESPONSE', function(event) {
var detail = event.detail;
if (!detail || !detail.id) return;
var handler = _downloadPending[detail.id];
if (handler) {
delete _downloadPending[detail.id];
if (detail.ok) handler.resolve(detail.payload);
else handler.reject(new Error(detail.error || 'Download bridge error'));
}
});
function _downloadRequest(type, payload) {
return new Promise(function(resolve, reject) {
var id = _downloadGenerateId();
_downloadPending[id] = { resolve: resolve, reject: reject };
sendToNative({ type: type, id: id, payload: payload || null });
setTimeout(function() {
if (_downloadPending[id]) {
delete _downloadPending[id];
reject(new Error('Download bridge timeout'));
}
}, 30000);
});
}
var _downloadSubscribers = [];
window.addEventListener('__MOVIX_DOWNLOAD_EVENT', function(event) {
var detail = event.detail;
if (!detail) return;
for (var i = 0; i < _downloadSubscribers.length; i++) {
try { _downloadSubscribers[i](detail); } catch (e) {}
}
});
function downloadSubscribe(cb) {
if (typeof cb !== 'function') return function() {};
_downloadSubscribers.push(cb);
return function() {
var idx = _downloadSubscribers.indexOf(cb);
if (idx >= 0) _downloadSubscribers.splice(idx, 1);
};
}
window.MovixBridge = {
isApp: true,
platform: 'android',
version: 1,
download: {
start: function(opts) { return _downloadRequest('MOVIX_DOWNLOAD_START', opts); },
pause: function(id) { return _downloadRequest('MOVIX_DOWNLOAD_PAUSE', { downloadId: id }); },
resume: function(id) { return _downloadRequest('MOVIX_DOWNLOAD_RESUME', { downloadId: id }); },
cancel: function(id) { return _downloadRequest('MOVIX_DOWNLOAD_CANCEL', { downloadId: id }); },
delete: function(id) { return _downloadRequest('MOVIX_DOWNLOAD_DELETE', { downloadId: id }); },
launch: function(id) { return _downloadRequest('MOVIX_DOWNLOAD_LAUNCH', { downloadId: id }); },
list: function() { return _downloadRequest('MOVIX_DOWNLOAD_LIST', null); },
get: function(id) { return _downloadRequest('MOVIX_DOWNLOAD_GET', { downloadId: id }); },
subscribe: downloadSubscribe,
},
};
console.log('[Movix App] Bridge runtime initialisé');
})();
true;

View file

@ -18,6 +18,7 @@ import BrowserToolbar from '../components/BrowserToolbar';
import MiniPill from '../components/MiniPill';
import MirrorErrorScreen from '../components/MirrorErrorScreen';
import { startCastShimEventForwarding } from '../services/bridge';
import { startDownloadEventForwarding } from '../services/downloadBridge';
import { useBrowserUIPrefs } from '../hooks/useBrowserUIPrefs';
import { useAddress } from '../context/AddressContext';
import SettingsScreen from './SettingsScreen';
@ -76,6 +77,11 @@ export default function BrowserScreen() {
return unsub;
}, []);
useEffect(() => {
const unsub = startDownloadEventForwarding(webViewRef);
return unsub;
}, []);
const onNavigationStateChange = useCallback((state: WebViewNavigation) => {
setCanGoBack(state.canGoBack);
setCanGoForward(state.canGoForward);

View file

@ -16,6 +16,10 @@ import {
stopCast,
subscribeCastSessionEvents,
} from './cast';
import {
handleDownloadBridgeMessage,
isDownloadBridgeMessage,
} from './downloadBridge';
/** Minimal interface required by the shim helpers — satisfied by both WebView and WebViewBrowserRef. */
interface InjectableRef {
@ -375,6 +379,12 @@ export async function handleBridgeMessage(
}
}
// Route MOVIX_DOWNLOAD_* vers le bridge des téléchargements custom.
if (isDownloadBridgeMessage(parsed)) {
await handleDownloadBridgeMessage(parsed, webViewRef);
return;
}
const req = parsed as BridgeRequest;
if (!req.type || !req.id) return;

View file

@ -0,0 +1,161 @@
/**
* Bridge des téléchargements custom (Android only).
*
* Côté WebView, le runtime expose `window.MovixBridge.download.*` qui pousse
* des messages typés `MOVIX_DOWNLOAD_*` via `ReactNativeWebView.postMessage`.
* Ce module les route vers `NativeModules.MovixDownloadModule` et renvoie la
* réponse au WebView via `__MOVIX_DOWNLOAD_RESPONSE`.
*
* En parallèle, on s'abonne aux events du module natif
* (`MovixDownloadProgress`, `MovixDownloadState`) et on les pousse au WebView
* via `__MOVIX_DOWNLOAD_EVENT` pour que la page Downloads se mette à jour
* en temps réel.
*/
import { DeviceEventEmitter, NativeModules, Platform, type EmitterSubscription } from 'react-native';
import { type RefObject } from 'react';
interface InjectableRef {
injectJavaScript: (script: string) => void;
}
interface DownloadModuleType {
start: (opts: {
url: string;
filename: string;
subFolder?: string;
headers?: Record<string, string>;
metadata?: Record<string, unknown>;
}) => Promise<{ id: string; targetPath: string }>;
pause: (id: string) => Promise<void>;
resume: (id: string) => Promise<void>;
cancel: (id: string) => Promise<void>;
delete: (id: string) => Promise<void>;
launch: (id: string) => Promise<void>;
list: () => Promise<unknown[]>;
get: (id: string) => Promise<unknown | null>;
}
const DownloadModule = (NativeModules.MovixDownloadModule ?? null) as DownloadModuleType | null;
export type DownloadBridgeRequest =
| { type: 'MOVIX_DOWNLOAD_START'; id: string; payload: Parameters<DownloadModuleType['start']>[0] }
| { type: 'MOVIX_DOWNLOAD_PAUSE'; id: string; payload: { downloadId: string } }
| { type: 'MOVIX_DOWNLOAD_RESUME'; id: string; payload: { downloadId: string } }
| { type: 'MOVIX_DOWNLOAD_CANCEL'; id: string; payload: { downloadId: string } }
| { type: 'MOVIX_DOWNLOAD_DELETE'; id: string; payload: { downloadId: string } }
| { type: 'MOVIX_DOWNLOAD_LAUNCH'; id: string; payload: { downloadId: string } }
| { type: 'MOVIX_DOWNLOAD_LIST'; id: string }
| { type: 'MOVIX_DOWNLOAD_GET'; id: string; payload: { downloadId: string } };
export function isDownloadBridgeMessage(msg: unknown): msg is DownloadBridgeRequest {
return (
!!msg &&
typeof msg === 'object' &&
typeof (msg as { type?: unknown }).type === 'string' &&
((msg as { type: string }).type).startsWith('MOVIX_DOWNLOAD_')
);
}
function buildResponseScript(id: string, ok: boolean, payload: unknown, error: string | null): string {
const detail = JSON.stringify({ id, ok, payload: payload ?? null, error });
return `(function(){try{window.dispatchEvent(new CustomEvent('__MOVIX_DOWNLOAD_RESPONSE',{detail:${detail}}));}catch(e){}})(); true;`;
}
function buildEventScript(event: 'progress' | 'state', payload: unknown): string {
const detail = JSON.stringify({ event, payload });
return `(function(){try{window.dispatchEvent(new CustomEvent('__MOVIX_DOWNLOAD_EVENT',{detail:${detail}}));}catch(e){}})(); true;`;
}
function sendResponse(
webViewRef: RefObject<InjectableRef | null>,
id: string,
ok: boolean,
payload: unknown = null,
error: string | null = null,
) {
webViewRef.current?.injectJavaScript(buildResponseScript(id, ok, payload, error));
}
export async function handleDownloadBridgeMessage(
req: DownloadBridgeRequest,
webViewRef: RefObject<InjectableRef | null>,
): Promise<void> {
if (Platform.OS !== 'android' || !DownloadModule) {
sendResponse(webViewRef, req.id, false, null, 'Downloads not available on this platform');
return;
}
try {
switch (req.type) {
case 'MOVIX_DOWNLOAD_START': {
const result = await DownloadModule.start(req.payload);
sendResponse(webViewRef, req.id, true, result);
return;
}
case 'MOVIX_DOWNLOAD_PAUSE': {
await DownloadModule.pause(req.payload.downloadId);
sendResponse(webViewRef, req.id, true);
return;
}
case 'MOVIX_DOWNLOAD_RESUME': {
await DownloadModule.resume(req.payload.downloadId);
sendResponse(webViewRef, req.id, true);
return;
}
case 'MOVIX_DOWNLOAD_CANCEL': {
await DownloadModule.cancel(req.payload.downloadId);
sendResponse(webViewRef, req.id, true);
return;
}
case 'MOVIX_DOWNLOAD_DELETE': {
await DownloadModule.delete(req.payload.downloadId);
sendResponse(webViewRef, req.id, true);
return;
}
case 'MOVIX_DOWNLOAD_LAUNCH': {
await DownloadModule.launch(req.payload.downloadId);
sendResponse(webViewRef, req.id, true);
return;
}
case 'MOVIX_DOWNLOAD_LIST': {
const list = await DownloadModule.list();
sendResponse(webViewRef, req.id, true, list);
return;
}
case 'MOVIX_DOWNLOAD_GET': {
const entry = await DownloadModule.get(req.payload.downloadId);
sendResponse(webViewRef, req.id, true, entry);
return;
}
}
} catch (err) {
const message = (err as Error)?.message ?? 'Download bridge error';
sendResponse(webViewRef, req.id, false, null, message);
}
}
/**
* S'abonne aux events natifs du module download et les pousse au WebView.
* À appeler une fois au mount de `BrowserScreen`. Retourne unsubscribe.
*/
export function startDownloadEventForwarding(
webViewRef: RefObject<InjectableRef | null>,
): () => void {
if (Platform.OS !== 'android' || !DownloadModule) {
return () => {};
}
const subs: EmitterSubscription[] = [
DeviceEventEmitter.addListener('MovixDownloadProgress', (payload: unknown) => {
webViewRef.current?.injectJavaScript(buildEventScript('progress', payload));
}),
DeviceEventEmitter.addListener('MovixDownloadState', (payload: unknown) => {
webViewRef.current?.injectJavaScript(buildEventScript('state', payload));
}),
];
return () => {
subs.forEach(s => s.remove());
};
}

View file

@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import Snowfall from 'react-snowfall';
import { useLocation, useNavigate } from 'react-router-dom';
import { PrefetchLink as Link } from '@/routing/PrefetchLink';
import { Film, Search, Menu, X, Star, Tv2, Users, Clapperboard, Bell, Tv, Lightbulb, Network, List, Radio, Unlock, ChevronDown, ExternalLink, LayoutGrid, Settings, Dices, Sparkles, HelpCircle, Github } from 'lucide-react';
import { Film, Search, Menu, X, Star, Tv2, Users, Clapperboard, Bell, Tv, Lightbulb, Network, List, Radio, Unlock, ChevronDown, ExternalLink, LayoutGrid, Settings, Dices, Sparkles, HelpCircle, Github, Download as DownloadIcon } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import ProfileMenu from './ProfileMenu';
import NotificationsPopup from './NotificationsPopup';
@ -11,6 +11,7 @@ import { encodeId } from '../utils/idEncoder';
import { useSearch } from '../context/SearchContext';
import { isUserVip } from '../utils/authUtils';
import { isMovixApp, getMovixBridge } from '../utils/appBridge';
import { useTranslation } from 'react-i18next';
import { SquareBackground } from './ui/square-background';
import { APRIL_FOOLS_ADMIN_PATH, isAprilFoolsAdminEnabled } from '../utils/aprilFools';
@ -69,6 +70,31 @@ const Header: React.FC = () => {
const [notificationsDisabled, setNotificationsDisabled] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isVip, setIsVip] = useState(false);
// Compteur de DL actifs in-app (running/queued/paused). Visible uniquement
// dans l'app native ; rafraîchi via subscribe au bridge.
const inMovixApp = isMovixApp();
const [activeDownloads, setActiveDownloads] = useState(0);
useEffect(() => {
const bridge = getMovixBridge();
if (!bridge) return;
const computeFromList = async () => {
try {
const list = await bridge.download.list();
const active = list.filter((e) => e.status === 'running' || e.status === 'queued' || e.status === 'paused').length;
setActiveDownloads(active);
} catch {
// bridge offline, on garde la valeur précédente
}
};
computeFromList();
const unsub = bridge.download.subscribe((evt) => {
if (evt.event === 'state') {
computeFromList();
}
});
return unsub;
}, []);
const [headerQuery, setHeaderQuery] = useState('');
const searchInputRef = useRef<HTMLInputElement>(null);
@ -485,6 +511,22 @@ const Header: React.FC = () => {
<Search size={20} />
</motion.button>
{/* Téléchargements in-app (Android uniquement) */}
{inMovixApp && (
<Link
to="/downloads"
className="relative flex items-center justify-center p-2 text-gray-400 hover:text-white transition-colors"
title={t('downloads.title')}
>
<DownloadIcon size={20} />
{activeDownloads > 0 && (
<span className="absolute -top-0.5 -right-0.5 bg-indigo-600 text-white text-[10px] rounded-full h-4 min-w-4 flex items-center justify-center px-1 font-bold">
{activeDownloads > 9 ? '9+' : activeDownloads}
</span>
)}
</Link>
)}
{/* Notifications */}
{isAuthenticated && !notificationsDisabled && (
<div className="relative" ref={notificationsRef}>

View file

@ -4253,7 +4253,56 @@
"name": "BestDebrid",
"desc": "Final link fetched directly from the browser"
}
}
},
"appDownloadBtn": "Download in the app",
"appDownloadStarting": "Starting download…",
"appDownloadAgain": "Restart in the app",
"appDownloadStarted": "Download added to the app",
"appDownloadStartedHint": "The file is being downloaded inside Movix.",
"appDownloadOpenList": "View my downloads",
"appDownloadUnavailable": "Movix application not detected.",
"appDownloadError": "Could not start the download."
},
"downloads": {
"title": "My downloads",
"subtitle": "Files stored inside the Movix app",
"backToHome": "Back to home",
"appOnlyTitle": "Only available inside the app",
"appOnlyDesc": "This page only makes sense inside the Movix Android app, where downloads are stored.",
"loading": "Loading…",
"empty": "No downloads yet",
"emptyHint": "Trigger a download from the Debrider page to find it here.",
"activeLabel": "Active",
"etaPrefix": "ETA",
"failedHint": "A download failed. Use Resume to retry.",
"fileCountSuffix": "file(s)",
"unknownTitle": "Unknown title",
"unknownSeason": "Unknown season",
"seasonLabel": "Season {{n}}",
"group": {
"movie": "Movies",
"series": "Series",
"animes": "Animes",
"misc": "Other"
},
"status": {
"queued": "Queued",
"running": "Running",
"paused": "Paused",
"done": "Done",
"failed": "Failed",
"cancelled": "Cancelled"
},
"actions": {
"pause": "Pause",
"resume": "Resume",
"cancel": "Cancel",
"delete": "Delete",
"launch": "Play"
},
"deleteConfirmTitle": "Delete this file?",
"deleteConfirmCancel": "Cancel",
"deleteConfirmOk": "Delete"
},
"francetv": {
"title": "France TV",

View file

@ -4253,7 +4253,56 @@
"name": "BestDebrid",
"desc": "Lien final obtenu directement côté navigateur"
}
}
},
"appDownloadBtn": "Télécharger dans l'application",
"appDownloadStarting": "Lancement du téléchargement…",
"appDownloadAgain": "Relancer dans l'application",
"appDownloadStarted": "Téléchargement ajouté à l'application",
"appDownloadStartedHint": "Le fichier est en cours de téléchargement dans Movix.",
"appDownloadOpenList": "Voir mes téléchargements",
"appDownloadUnavailable": "L'application Movix n'est pas détectée.",
"appDownloadError": "Le téléchargement n'a pas pu démarrer."
},
"downloads": {
"title": "Mes téléchargements",
"subtitle": "Fichiers stockés dans l'application Movix",
"backToHome": "Retour à l'accueil",
"appOnlyTitle": "Disponible uniquement dans l'application",
"appOnlyDesc": "Cette page n'a de sens que dans l'application Android Movix, où les téléchargements sont stockés.",
"loading": "Chargement…",
"empty": "Aucun téléchargement",
"emptyHint": "Lance un téléchargement depuis la page Débrideur pour le retrouver ici.",
"activeLabel": "Actifs",
"etaPrefix": "ETA",
"failedHint": "Un téléchargement a échoué. Utilise le bouton Reprendre pour réessayer.",
"fileCountSuffix": "fichier(s)",
"unknownTitle": "Titre inconnu",
"unknownSeason": "Saison inconnue",
"seasonLabel": "Saison {{n}}",
"group": {
"movie": "Films",
"series": "Séries",
"animes": "Animés",
"misc": "Autres"
},
"status": {
"queued": "En attente",
"running": "En cours",
"paused": "En pause",
"done": "Terminé",
"failed": "Échec",
"cancelled": "Annulé"
},
"actions": {
"pause": "Mettre en pause",
"resume": "Reprendre",
"cancel": "Annuler",
"delete": "Supprimer",
"launch": "Lancer"
},
"deleteConfirmTitle": "Supprimer ce fichier ?",
"deleteConfirmCancel": "Annuler",
"deleteConfirmOk": "Supprimer"
},
"francetv": {
"title": "France TV",

View file

@ -31,6 +31,13 @@ import AnimatedBorderCard from '../components/ui/animated-border-card';
import { Button } from '../components/ui/button';
import { BESTDEBRID_API_BASE, MAIN_API, PROXIES_EMBED_API } from '../config/runtime';
import { getVipHeaders } from '../utils/vipUtils';
import {
buildDownloadSubFolder,
getMovixBridge,
isMovixApp,
type MovixDownloadMetadata,
} from '../utils/appBridge';
import { Smartphone } from 'lucide-react';
// Hébergeurs non supportés
const unsupportedHosts = [
@ -283,6 +290,37 @@ const DebridPage: React.FC = () => {
const isVip = localStorage.getItem('is_vip') === 'true';
const hasAutoDebrided = useRef(false);
// Métadonnées film/série/anime propagées par DownloadPage via query string.
// Utilisées par le téléchargement in-app (Android) pour ranger correctement
// le fichier et afficher de jolies cartes dans /downloads. Stockées une seule
// fois au mount (avant que setSearchParams({}) ne les efface).
const [mediaContext] = useState<MovixDownloadMetadata>(() => {
const out: MovixDownloadMetadata = {};
const kind = searchParams.get('kind');
if (kind === 'movie' || kind === 'series' || kind === 'animes') out.type = kind;
const tmdb = searchParams.get('tmdb');
if (tmdb) out.tmdbId = tmdb;
const titleParam = searchParams.get('title');
if (titleParam) out.title = titleParam;
const poster = searchParams.get('poster');
if (poster) out.poster = poster;
const s = searchParams.get('s');
if (s && !Number.isNaN(parseInt(s, 10))) out.season = parseInt(s, 10);
const e = searchParams.get('e');
if (e && !Number.isNaN(parseInt(e, 10))) out.episode = parseInt(e, 10);
const etitle = searchParams.get('etitle');
if (etitle) out.episodeTitle = etitle;
const lang = searchParams.get('lang');
if (lang) out.language = lang;
const q = searchParams.get('q');
if (q) out.quality = q;
return out;
});
const [appDownloadState, setAppDownloadState] = useState<'idle' | 'starting' | 'started' | 'error'>('idle');
const [appDownloadError, setAppDownloadError] = useState<string | null>(null);
const inApp = isMovixApp();
const providerOptions: DebridProvider[] = ['deepbrid', 'realdebrid', 'bestdebrid'];
const isSubmitDisabled = isLoading || !url.trim();
@ -459,6 +497,39 @@ const DebridPage: React.FC = () => {
});
};
const handleAppDownload = useCallback(async (debridResult: DebridResult) => {
const bridge = getMovixBridge();
if (!bridge) {
setAppDownloadError(t('debrid.appDownloadUnavailable'));
setAppDownloadState('error');
return;
}
setAppDownloadState('starting');
setAppDownloadError(null);
try {
const metadata: MovixDownloadMetadata = {
...mediaContext,
provider: debridResult.provider,
host: debridResult.host,
originalLink: url || undefined,
};
await bridge.download.start({
url: debridResult.link,
filename: debridResult.filename,
subFolder: buildDownloadSubFolder(metadata),
metadata,
});
setAppDownloadState('started');
toast.success(t('debrid.appDownloadStarted'));
} catch (err) {
const message = err instanceof Error ? err.message : t('debrid.appDownloadError');
setAppDownloadError(message);
setAppDownloadState('error');
toast.error(message);
}
}, [mediaContext, t, url]);
const formatFileSize = (bytes: number): string => {
if (!bytes || bytes === 0) return '';
if (bytes > 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`;
@ -659,6 +730,37 @@ const DebridPage: React.FC = () => {
<Copy className="w-5 h-5" />
</button>
</div>
{inApp && (
<div className="pt-1">
<button
onClick={() => handleAppDownload(result)}
disabled={appDownloadState === 'starting'}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-900/50 disabled:cursor-wait rounded-xl text-white font-medium transition-colors"
>
{appDownloadState === 'starting' ? (
<Loader className="w-5 h-5 animate-spin" />
) : (
<Smartphone className="w-5 h-5" />
)}
{appDownloadState === 'starting'
? t('debrid.appDownloadStarting')
: appDownloadState === 'started'
? t('debrid.appDownloadAgain')
: t('debrid.appDownloadBtn')}
</button>
{appDownloadError && (
<p className="mt-2 text-xs text-red-300">{appDownloadError}</p>
)}
{appDownloadState === 'started' && !appDownloadError && (
<p className="mt-2 text-xs text-indigo-200/70">
{t('debrid.appDownloadStartedHint')}{' '}
<Link to="/downloads" className="underline hover:text-white">
{t('debrid.appDownloadOpenList')}
</Link>
</p>
)}
</div>
)}
</div>
</AnimatedBorderCard>
</motion.div>

View file

@ -557,7 +557,14 @@ const LinkSelector: React.FC<{
decodedLink: DecodedLink | null;
error: string | null;
queueInfo?: { size: number } | null;
}> = ({ isOpen, onClose, title, selectedLink, isDecoding, decodedLink, error, queueInfo }) => {
// Métadonnées propagées au /debrid pour que le download in-app les retrouve.
mediaKind: 'movie' | 'series' | 'animes';
tmdbId: string | undefined;
posterPath: string | null | undefined;
currentSeason?: number;
currentEpisode?: number;
episodeTitle?: string;
}> = ({ isOpen, onClose, title, selectedLink, isDecoding, decodedLink, error, queueInfo, mediaKind, tmdbId, posterPath, currentSeason, currentEpisode, episodeTitle }) => {
const { t, i18n } = useTranslation();
const [isClosing, setIsClosing] = useState(false);
const isVipUser = localStorage.getItem('is_vip') === 'true';
@ -775,7 +782,23 @@ const LinkSelector: React.FC<{
<button
onClick={() => {
const linkUrl = getEmbedUrl(decodedLink!);
if (linkUrl) navigate(`/debrid?link=${encodeURIComponent(linkUrl)}`);
if (!linkUrl) return;
const params = new URLSearchParams({ link: linkUrl });
params.set('kind', mediaKind);
if (tmdbId) params.set('tmdb', tmdbId);
if (title) params.set('title', title);
if (posterPath) params.set('poster', posterPath);
if (mediaKind !== 'movie') {
if (currentSeason != null) params.set('s', String(currentSeason));
if (currentEpisode != null) params.set('e', String(currentEpisode));
if (episodeTitle) params.set('etitle', episodeTitle);
}
const meta = decodedLink?.metadata || selectedLink || {};
const lang = (meta as { language?: string }).language;
const quality = (meta as { quality?: string }).quality;
if (lang) params.set('lang', lang);
if (quality) params.set('q', quality);
navigate(`/debrid?${params.toString()}`);
}}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-yellow-600 hover:bg-yellow-700 rounded-lg text-white text-sm font-medium transition-colors"
>
@ -853,6 +876,12 @@ const DownloadPage: React.FC = () => {
};
const [tmdbDetails, setTmdbDetails] = useState<TMDBDetails | null>(null);
// mediaKind = catégorie utilisée par la page Downloads in-app pour grouper.
// - 'movie' : déduit directement du param URL.
// - 'series' / 'animes' : lit `title.type` retourné par /api/darkiworld/seasons.
const [mediaKind, setMediaKind] = useState<'movie' | 'series' | 'animes'>(
type === 'movie' ? 'movie' : 'series',
);
const [seasons, setSeasons] = useState<Season[]>([]);
const [episodes, setEpisodes] = useState<Episode[]>([]);
const [selectedSeason, setSelectedSeason] = useState<number>(1);
@ -1126,6 +1155,10 @@ const DownloadPage: React.FC = () => {
const fetchDarkiWorldSeasons = async (titleId: string, page: number = 1, append: boolean = false) => {
try {
const response = await axios.get(`${MAIN_API}/api/darkiworld/seasons/${titleId}?page=${page}&perPage=8&mode=auto`);
const titleType = response.data?.title?.type;
if (titleType === 'animes' || titleType === 'series') {
setMediaKind(titleType);
}
if (response.data.success && response.data.pagination) {
console.log('Pagination seasons:', response.data.pagination);
@ -1811,6 +1844,12 @@ const DownloadPage: React.FC = () => {
decodedLink={decodedLink}
error={error}
queueInfo={queueInfo}
mediaKind={mediaKind}
tmdbId={id}
posterPath={tmdbDetails?.poster_path}
currentSeason={selectedDarkiWorldSeason?.number ?? selectedSeason}
currentEpisode={selectedDarkiWorldEpisode?.episode_number ?? selectedEpisode}
episodeTitle={selectedDarkiWorldEpisode?.name}
/>
{showAdPopup && (
<AdFreePlayerAds

487
src/pages/DownloadsPage.tsx Normal file
View file

@ -0,0 +1,487 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { motion, AnimatePresence } from 'framer-motion';
import { PrefetchLink as Link } from '@/routing/PrefetchLink';
import {
ArrowLeft,
Smartphone,
Pause,
Play,
PlayCircle,
Trash2,
XCircle,
Loader,
CheckCircle2,
AlertCircle,
Film,
Tv,
Sparkles,
Folder,
} from 'lucide-react';
import { toast } from 'sonner';
import { SquareBackground } from '../components/ui/square-background';
import AnimatedBorderCard from '../components/ui/animated-border-card';
import {
getMovixBridge,
isMovixApp,
type MovixDownloadEntry,
type MovixDownloadStatus,
type MovixDownloadType,
} from '../utils/appBridge';
interface ProgressTick {
bytesDownloaded: number;
bytesTotal: number;
speedBytesPerSec: number;
}
const formatBytes = (bytes: number): string => {
if (!bytes || bytes <= 0) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
};
const formatSpeed = (bytesPerSec: number): string => {
if (!bytesPerSec || bytesPerSec <= 0) return '—';
return `${formatBytes(bytesPerSec)}/s`;
};
const formatEta = (seconds: number): string => {
if (!isFinite(seconds) || seconds <= 0) return '—';
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}h ${m}m`;
};
const statusColor = (status: MovixDownloadStatus): string => {
switch (status) {
case 'running': return 'text-indigo-300';
case 'queued': return 'text-white/60';
case 'paused': return 'text-amber-300';
case 'done': return 'text-green-400';
case 'failed': return 'text-red-400';
case 'cancelled': return 'text-white/40';
}
};
const DownloadCard: React.FC<{
entry: MovixDownloadEntry;
tick: ProgressTick | null;
onPause: (id: string) => void;
onResume: (id: string) => void;
onCancel: (id: string) => void;
onDelete: (id: string) => void;
onLaunch: (id: string) => void;
}> = ({ entry, tick, onPause, onResume, onCancel, onDelete, onLaunch }) => {
const { t } = useTranslation();
const [pendingDelete, setPendingDelete] = useState(false);
const downloaded = tick?.bytesDownloaded ?? entry.downloadedBytes;
const total = tick?.bytesTotal && tick.bytesTotal > 0 ? tick.bytesTotal : entry.totalBytes;
const speed = tick?.speedBytesPerSec ?? 0;
const pct = total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : 0;
const remaining = total > 0 ? total - downloaded : 0;
const etaSec = speed > 0 && remaining > 0 ? remaining / speed : 0;
const isRunning = entry.status === 'running';
const isPaused = entry.status === 'paused';
const isQueued = entry.status === 'queued';
const isDone = entry.status === 'done';
return (
<div className="p-4 bg-white/5 rounded-xl border border-white/5 hover:border-white/10 transition-colors">
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="text-sm font-medium text-white truncate">
{entry.metadata?.episodeTitle || entry.filename}
</p>
{!isDone && (
<span className={`text-[10px] uppercase tracking-wider ${statusColor(entry.status)}`}>
{t(`downloads.status.${entry.status}`)}
</span>
)}
</div>
{entry.metadata?.title && entry.metadata.title !== entry.filename && (
<p className="text-xs text-white/40 truncate">{entry.metadata.title}</p>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{isDone && (
<button
onClick={() => onLaunch(entry.id)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/15 hover:bg-green-500/25 text-green-400 rounded-lg text-xs font-medium transition-colors"
title={t('downloads.actions.launch')}
>
<PlayCircle className="w-3.5 h-3.5" />
{t('downloads.actions.launch')}
</button>
)}
{isRunning && (
<button onClick={() => onPause(entry.id)} className="p-1.5 text-amber-300 hover:bg-amber-300/10 rounded-lg" title={t('downloads.actions.pause')}>
<Pause className="w-4 h-4" />
</button>
)}
{(isPaused || entry.status === 'failed') && (
<button onClick={() => onResume(entry.id)} className="p-1.5 text-indigo-300 hover:bg-indigo-300/10 rounded-lg" title={t('downloads.actions.resume')}>
<Play className="w-4 h-4" />
</button>
)}
{(isRunning || isPaused || isQueued) && (
<button onClick={() => onCancel(entry.id)} className="p-1.5 text-white/60 hover:bg-white/10 rounded-lg" title={t('downloads.actions.cancel')}>
<XCircle className="w-4 h-4" />
</button>
)}
<button
onClick={() => setPendingDelete(true)}
className="p-1.5 text-red-400 hover:bg-red-400/10 rounded-lg"
title={t('downloads.actions.delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{!isDone && (
<>
<div className="mt-3 h-1.5 bg-white/5 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
entry.status === 'failed'
? 'bg-red-500'
: entry.status === 'paused'
? 'bg-amber-500'
: 'bg-indigo-500'
}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-white/40">
<span>{formatBytes(downloaded)} / {total > 0 ? formatBytes(total) : '?'}</span>
<span>{pct}%</span>
{isRunning && (
<>
<span>{formatSpeed(speed)}</span>
{etaSec > 0 && <span>{t('downloads.etaPrefix')} {formatEta(etaSec)}</span>}
</>
)}
{entry.status === 'failed' && entry.errorMessage && (
<span className="text-red-300">{entry.errorMessage}</span>
)}
</div>
</>
)}
{pendingDelete && (
<div className="mt-3 flex items-center justify-between gap-2 p-2.5 bg-red-900/20 border border-red-500/20 rounded-lg">
<p className="text-xs text-red-300">{t('downloads.deleteConfirmTitle')}</p>
<div className="flex gap-2 flex-shrink-0">
<button
onClick={() => setPendingDelete(false)}
className="px-2.5 py-1 text-xs text-white/60 hover:text-white bg-white/5 hover:bg-white/10 rounded-md transition-colors"
>
{t('downloads.deleteConfirmCancel')}
</button>
<button
onClick={() => { setPendingDelete(false); onDelete(entry.id); }}
className="px-2.5 py-1 text-xs text-red-400 hover:text-red-300 bg-red-500/10 hover:bg-red-500/20 rounded-md transition-colors font-medium"
>
{t('downloads.deleteConfirmOk')}
</button>
</div>
</div>
)}
</div>
);
};
const TypeIcon: React.FC<{ type: MovixDownloadType | 'misc' }> = ({ type }) => {
if (type === 'movie') return <Film className="w-5 h-5 text-indigo-400" />;
if (type === 'series') return <Tv className="w-5 h-5 text-emerald-400" />;
if (type === 'animes') return <Sparkles className="w-5 h-5 text-pink-400" />;
return <Folder className="w-5 h-5 text-white/40" />;
};
const DownloadsPage: React.FC = () => {
const { t } = useTranslation();
const inApp = isMovixApp();
const [entries, setEntries] = useState<MovixDownloadEntry[]>([]);
const [ticks, setTicks] = useState<Record<string, ProgressTick>>({});
const [loading, setLoading] = useState(true);
const fetchedOnce = useRef(false);
const refresh = useCallback(async () => {
const bridge = getMovixBridge();
if (!bridge) {
setLoading(false);
return;
}
try {
const list = await bridge.download.list();
setEntries(list ?? []);
} catch (err) {
console.warn('[downloads] list failed', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (fetchedOnce.current) return;
fetchedOnce.current = true;
refresh();
}, [refresh]);
useEffect(() => {
const bridge = getMovixBridge();
if (!bridge) return;
const unsub = bridge.download.subscribe((evt) => {
if (evt.event === 'progress') {
setTicks((prev) => ({ ...prev, [evt.payload.id]: evt.payload }));
} else if (evt.event === 'state') {
setEntries((prev) => {
const idx = prev.findIndex((e) => e.id === evt.payload.id);
if (idx === -1) return [evt.payload, ...prev];
const next = prev.slice();
next[idx] = evt.payload;
return next;
});
}
});
return unsub;
}, []);
const handlePause = useCallback(async (id: string) => {
const b = getMovixBridge(); if (!b) return;
try { await b.download.pause(id); } catch (e) { toast.error((e as Error).message); }
}, []);
const handleResume = useCallback(async (id: string) => {
const b = getMovixBridge(); if (!b) return;
try { await b.download.resume(id); } catch (e) { toast.error((e as Error).message); }
}, []);
const handleCancel = useCallback(async (id: string) => {
const b = getMovixBridge(); if (!b) return;
try { await b.download.cancel(id); } catch (e) { toast.error((e as Error).message); }
}, []);
const handleDelete = useCallback(async (id: string) => {
const b = getMovixBridge(); if (!b) return;
try {
await b.download.delete(id);
setEntries((prev) => prev.filter((e) => e.id !== id));
setTicks((prev) => { const n = { ...prev }; delete n[id]; return n; });
} catch (e) { toast.error((e as Error).message); }
}, []);
const handleLaunch = useCallback(async (id: string) => {
const b = getMovixBridge(); if (!b) return;
try { await b.download.launch(id); } catch (e) { toast.error((e as Error).message); }
}, []);
const grouped = useMemo(() => {
const groups: Record<'movie' | 'series' | 'animes' | 'misc', MovixDownloadEntry[]> = {
movie: [],
series: [],
animes: [],
misc: [],
};
entries.forEach((e) => {
const k = (e.metadata?.type ?? 'misc') as keyof typeof groups;
(groups[k] ?? groups.misc).push(e);
});
return groups;
}, [entries]);
const activeCount = entries.filter((e) => e.status === 'running' || e.status === 'queued' || e.status === 'paused').length;
if (!inApp) {
return (
<SquareBackground squareSize={48} borderColor="rgba(99, 102, 241, 0.10)" className="min-h-screen bg-black text-white">
<div className="container mx-auto px-6 py-12 relative z-10">
<Link to="/" className="inline-flex items-center text-white/50 hover:text-white transition-colors mb-8">
<ArrowLeft className="w-5 h-5 mr-2" />
{t('downloads.backToHome')}
</Link>
<div className="max-w-lg mx-auto">
<AnimatedBorderCard highlightColor="99 102 241" backgroundColor="12 12 12" className="p-8 text-center">
<Smartphone className="w-12 h-12 text-indigo-400 mx-auto mb-4" />
<h2 className="text-xl font-bold text-white mb-2">{t('downloads.appOnlyTitle')}</h2>
<p className="text-white/60 text-sm">{t('downloads.appOnlyDesc')}</p>
</AnimatedBorderCard>
</div>
</div>
</SquareBackground>
);
}
const renderGroup = (kind: 'movie' | 'series' | 'animes' | 'misc', items: MovixDownloadEntry[]) => {
if (items.length === 0) return null;
// Series + animes : grouper par titre puis par saison.
if (kind === 'series' || kind === 'animes') {
const byTitle: Record<string, MovixDownloadEntry[]> = {};
items.forEach((e) => {
const k = String(e.metadata?.tmdbId ?? e.metadata?.title ?? 'unknown');
(byTitle[k] ??= []).push(e);
});
return (
<motion.div
key={kind}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-4"
>
<div className="flex items-center gap-2 px-1">
<TypeIcon type={kind} />
<h2 className="text-base font-semibold text-white">{t(`downloads.group.${kind}`)}</h2>
<span className="text-xs text-white/30 bg-white/5 px-2 py-0.5 rounded-full">{items.length}</span>
</div>
{Object.entries(byTitle).map(([titleKey, group]) => {
const repr = group[0];
const bySeason: Record<string, MovixDownloadEntry[]> = {};
group.forEach((e) => {
const s = e.metadata?.season != null ? String(e.metadata.season) : '?';
(bySeason[s] ??= []).push(e);
});
const seasonKeys = Object.keys(bySeason).sort((a, b) => {
const an = Number(a); const bn = Number(b);
if (Number.isNaN(an)) return 1;
if (Number.isNaN(bn)) return -1;
return an - bn;
});
return (
<AnimatedBorderCard key={titleKey} highlightColor={kind === 'animes' ? '236 72 153' : '16 185 129'} backgroundColor="12 12 12" className="p-4 space-y-3">
<div className="flex items-center gap-3">
{repr.metadata?.poster && (
<img
src={`https://image.tmdb.org/t/p/w92${repr.metadata.poster}`}
alt=""
className="w-10 h-14 object-cover rounded"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
)}
<div>
<p className="text-white font-medium">{repr.metadata?.title || t('downloads.unknownTitle')}</p>
<p className="text-xs text-white/40">{group.length} {t('downloads.fileCountSuffix')}</p>
</div>
</div>
{seasonKeys.map((s) => (
<div key={s} className="space-y-2">
<p className="text-xs uppercase tracking-wider text-white/40 pl-1">
{s === '?' ? t('downloads.unknownSeason') : t('downloads.seasonLabel', { n: s })}
</p>
<div className="space-y-2">
{bySeason[s]
.sort((a, b) => (Number(a.metadata?.episode ?? 0) - Number(b.metadata?.episode ?? 0)))
.map((entry) => (
<DownloadCard
key={entry.id}
entry={entry}
tick={ticks[entry.id] ?? null}
onPause={handlePause}
onResume={handleResume}
onCancel={handleCancel}
onDelete={handleDelete}
onLaunch={handleLaunch}
/>
))}
</div>
</div>
))}
</AnimatedBorderCard>
);
})}
</motion.div>
);
}
// Movies + misc : plat.
return (
<motion.div
key={kind}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-3"
>
<div className="flex items-center gap-2 px-1">
<TypeIcon type={kind} />
<h2 className="text-base font-semibold text-white">{t(`downloads.group.${kind}`)}</h2>
<span className="text-xs text-white/30 bg-white/5 px-2 py-0.5 rounded-full">{items.length}</span>
</div>
<div className="space-y-2">
{items
.sort((a, b) => b.createdAt - a.createdAt)
.map((entry) => (
<DownloadCard
key={entry.id}
entry={entry}
tick={ticks[entry.id] ?? null}
onPause={handlePause}
onResume={handleResume}
onCancel={handleCancel}
onDelete={handleDelete}
onLaunch={handleLaunch}
/>
))}
</div>
</motion.div>
);
};
return (
<SquareBackground squareSize={48} borderColor="rgba(99, 102, 241, 0.10)" className="min-h-screen bg-black text-white">
<div className="container mx-auto px-4 sm:px-6 py-8 sm:py-12 relative z-10">
<Link to="/" className="inline-flex items-center text-white/50 hover:text-white transition-colors mb-8">
<ArrowLeft className="w-5 h-5 mr-2" />
{t('downloads.backToHome')}
</Link>
<div className="max-w-3xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl sm:text-3xl font-black tracking-tight text-white">{t('downloads.title')}</h1>
<p className="text-sm text-white/40 mt-1">{t('downloads.subtitle')}</p>
</div>
<div className="text-right">
<p className="text-xs uppercase tracking-wider text-white/40">{t('downloads.activeLabel')}</p>
<p className="text-2xl font-bold text-indigo-300">{activeCount}</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-20 text-white/40">
<Loader className="w-5 h-5 animate-spin mr-2" /> {t('downloads.loading')}
</div>
) : entries.length === 0 ? (
<AnimatedBorderCard highlightColor="99 102 241" backgroundColor="12 12 12" className="p-10 text-center">
<CheckCircle2 className="w-10 h-10 text-white/20 mx-auto mb-3" />
<p className="text-white/60">{t('downloads.empty')}</p>
<p className="text-xs text-white/30 mt-1">{t('downloads.emptyHint')}</p>
</AnimatedBorderCard>
) : (
<AnimatePresence mode="popLayout">
<div className="space-y-6">
{renderGroup('movie', grouped.movie)}
{renderGroup('series', grouped.series)}
{renderGroup('animes', grouped.animes)}
{renderGroup('misc', grouped.misc)}
</div>
</AnimatePresence>
)}
{entries.some((e) => e.status === 'failed') && (
<div className="mt-6 p-3 bg-red-900/10 border border-red-500/20 rounded-xl flex items-center gap-2 text-xs text-red-300/70">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{t('downloads.failedHint')}
</div>
)}
</div>
</div>
</SquareBackground>
);
};
export default DownloadsPage;

View file

@ -91,6 +91,7 @@ export const ROUTES: RouteEntry[] = [
{ path: '/admin', loader: lz(() => import('../pages/AdminPage')) },
{ path: '/download/:type/:id', loader: lz(() => import('../pages/DownloadPage')) },
{ path: '/debrid', loader: lz(() => import('../pages/DebridPage')) },
{ path: '/downloads', loader: lz(() => import('../pages/DownloadsPage')) },
{ path: '/roulette', loader: lz(() => import('../pages/RoulettePage')) },
{ path: '/suggestion', loader: lz(() => import('../pages/SuggestionPage')) },
{ path: '/extension', loader: lz(() => import('../pages/ExtensionPage')) },

122
src/utils/appBridge.ts Normal file
View file

@ -0,0 +1,122 @@
/**
* Détection et wrappers du bridge exposé par l'app native Movix (Android).
*
* L'app RN injecte avant chargement de page un objet `window.MovixBridge`
* dont la simple présence (`isApp === true`) est l'indicateur le plus
* fiable que la page tourne dans l'app — plus robuste qu'un sniff UA.
*
* Toutes les méthodes retournent une Promise. Hors app, elles throw
* immédiatement : les call sites doivent gate via `isMovixApp()`.
*/
export type MovixDownloadStatus = 'queued' | 'running' | 'paused' | 'done' | 'failed' | 'cancelled';
export type MovixDownloadType = 'movie' | 'series' | 'animes';
export interface MovixDownloadMetadata {
type?: MovixDownloadType;
tmdbId?: number | string;
title?: string;
poster?: string;
season?: number;
episode?: number;
episodeTitle?: string;
language?: string;
quality?: string;
provider?: string;
host?: string;
originalLink?: string;
// Champs libres : le natif ne les inspecte pas, on les relit tel quel.
[k: string]: unknown;
}
export interface MovixDownloadStartOpts {
url: string;
filename: string;
subFolder?: string;
headers?: Record<string, string>;
metadata?: MovixDownloadMetadata;
}
export interface MovixDownloadEntry {
id: string;
url: string;
filename: string;
targetPath: string;
totalBytes: number;
downloadedBytes: number;
status: MovixDownloadStatus;
errorMessage: string | null;
createdAt: number;
updatedAt: number;
metadata: MovixDownloadMetadata;
headers: Record<string, string>;
}
export interface MovixDownloadProgressEvent {
event: 'progress';
payload: {
id: string;
bytesDownloaded: number;
bytesTotal: number;
speedBytesPerSec: number;
};
}
export interface MovixDownloadStateEvent {
event: 'state';
payload: MovixDownloadEntry;
}
export type MovixDownloadEvent = MovixDownloadProgressEvent | MovixDownloadStateEvent;
interface MovixBridge {
isApp: true;
platform: 'android' | 'ios';
version: number;
download: {
start: (opts: MovixDownloadStartOpts) => Promise<{ id: string; targetPath: string }>;
pause: (id: string) => Promise<void>;
resume: (id: string) => Promise<void>;
cancel: (id: string) => Promise<void>;
delete: (id: string) => Promise<void>;
launch: (id: string) => Promise<void>;
list: () => Promise<MovixDownloadEntry[]>;
get: (id: string) => Promise<MovixDownloadEntry | null>;
subscribe: (cb: (evt: MovixDownloadEvent) => void) => () => void;
};
}
declare global {
interface Window {
MovixBridge?: MovixBridge;
}
}
export function getMovixBridge(): MovixBridge | null {
if (typeof window === 'undefined') return null;
const bridge = window.MovixBridge;
if (!bridge || bridge.isApp !== true) return null;
return bridge;
}
export function isMovixApp(): boolean {
return getMovixBridge() !== null;
}
export function isMovixAndroid(): boolean {
const b = getMovixBridge();
return b !== null && b.platform === 'android';
}
/**
* Tag « sous-dossier » sûr pour l'organisation sur disque côté app.
* On garde du JSON court pour la metadata, mais sur disque on range par type.
*/
export function buildDownloadSubFolder(metadata: MovixDownloadMetadata | undefined): string {
const type = metadata?.type;
if (type === 'movie') return 'movies';
if (type === 'series') return 'series';
if (type === 'animes') return 'animes';
return 'misc';
}