diff --git a/app/android/app/build.gradle b/app/android/app/build.gradle index cc92a3d..2236fff 100644 --- a/app/android/app/build.gradle +++ b/app/android/app/build.gradle @@ -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 diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 39e0e2d..953c9a4 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,9 @@ + + + + + + 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() ?: "{}", + ) + } + } +} diff --git a/app/android/app/src/main/java/com/movix/app/download/DownloadEventBus.kt b/app/android/app/src/main/java/com/movix/app/download/DownloadEventBus.kt new file mode 100644 index 0000000..cb0c80d --- /dev/null +++ b/app/android/app/src/main/java/com/movix/app/download/DownloadEventBus.kt @@ -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() + + 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) } } + } +} diff --git a/app/android/app/src/main/java/com/movix/app/download/DownloadJob.kt b/app/android/app/src/main/java/com/movix/app/download/DownloadJob.kt new file mode 100644 index 0000000..2d2f81a --- /dev/null +++ b/app/android/app/src/main/java/com/movix/app/download/DownloadJob.kt @@ -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 là où 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 { + 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() + } + } +} diff --git a/app/android/app/src/main/java/com/movix/app/download/DownloadModule.kt b/app/android/app/src/main/java/com/movix/app/download/DownloadModule.kt new file mode 100644 index 0000000..42cad3e --- /dev/null +++ b/app/android/app/src/main/java/com/movix/app/download/DownloadModule.kt @@ -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() } +} diff --git a/app/android/app/src/main/java/com/movix/app/download/DownloadPackage.kt b/app/android/app/src/main/java/com/movix/app/download/DownloadPackage.kt new file mode 100644 index 0000000..df3e968 --- /dev/null +++ b/app/android/app/src/main/java/com/movix/app/download/DownloadPackage.kt @@ -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 { + return listOf(DownloadModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} diff --git a/app/android/app/src/main/java/com/movix/app/download/DownloadService.kt b/app/android/app/src/main/java/com/movix/app/download/DownloadService.kt new file mode 100644 index 0000000..a258639 --- /dev/null +++ b/app/android/app/src/main/java/com/movix/app/download/DownloadService.kt @@ -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() + private val futures = ConcurrentHashMap>() + + 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" + } +} diff --git a/app/android/app/src/main/java/com/movix/app/download/DownloadStore.kt b/app/android/app/src/main/java/com/movix/app/download/DownloadStore.kt new file mode 100644 index 0000000..6c0f298 --- /dev/null +++ b/app/android/app/src/main/java/com/movix/app/download/DownloadStore.kt @@ -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 { + 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) { + 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" + } +} diff --git a/app/android/gradle.properties b/app/android/gradle.properties index 171fbcc..e3acf6a 100644 --- a/app/android/gradle.properties +++ b/app/android/gradle.properties @@ -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 diff --git a/app/android/gradlew b/app/android/gradlew old mode 100644 new mode 100755 diff --git a/app/src/injection/bridge-runtime.ts b/app/src/injection/bridge-runtime.ts index e1015e1..3bd88a8 100644 --- a/app/src/injection/bridge-runtime.ts +++ b/app/src/injection/bridge-runtime.ts @@ -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; diff --git a/app/src/screens/BrowserScreen.tsx b/app/src/screens/BrowserScreen.tsx index 998279c..a60c673 100644 --- a/app/src/screens/BrowserScreen.tsx +++ b/app/src/screens/BrowserScreen.tsx @@ -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); diff --git a/app/src/services/bridge.ts b/app/src/services/bridge.ts index dd7a1e6..e7dfd06 100644 --- a/app/src/services/bridge.ts +++ b/app/src/services/bridge.ts @@ -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; diff --git a/app/src/services/downloadBridge.ts b/app/src/services/downloadBridge.ts new file mode 100644 index 0000000..8291edd --- /dev/null +++ b/app/src/services/downloadBridge.ts @@ -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; + metadata?: Record; + }) => Promise<{ id: string; targetPath: string }>; + pause: (id: string) => Promise; + resume: (id: string) => Promise; + cancel: (id: string) => Promise; + delete: (id: string) => Promise; + launch: (id: string) => Promise; + list: () => Promise; + get: (id: string) => Promise; +} + +const DownloadModule = (NativeModules.MovixDownloadModule ?? null) as DownloadModuleType | null; + +export type DownloadBridgeRequest = + | { type: 'MOVIX_DOWNLOAD_START'; id: string; payload: Parameters[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, + 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, +): Promise { + 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, +): () => 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()); + }; +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index b4b0844..b4dbc2f 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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(null); @@ -485,6 +511,22 @@ const Header: React.FC = () => { + {/* Téléchargements in-app (Android uniquement) */} + {inMovixApp && ( + + + {activeDownloads > 0 && ( + + {activeDownloads > 9 ? '9+' : activeDownloads} + + )} + + )} + {/* Notifications */} {isAuthenticated && !notificationsDisabled && (
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0227390..7d3e42c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 057cb41..db9d9e1 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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", diff --git a/src/pages/DebridPage.tsx b/src/pages/DebridPage.tsx index 49c0d98..4d2ea8b 100644 --- a/src/pages/DebridPage.tsx +++ b/src/pages/DebridPage.tsx @@ -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(() => { + 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(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 = () => {
+ {inApp && ( +
+ + {appDownloadError && ( +

{appDownloadError}

+ )} + {appDownloadState === 'started' && !appDownloadError && ( +

+ {t('debrid.appDownloadStartedHint')}{' '} + + {t('debrid.appDownloadOpenList')} + +

+ )} +
+ )} diff --git a/src/pages/DownloadPage.tsx b/src/pages/DownloadPage.tsx index 89ffd8b..5bf2c28 100644 --- a/src/pages/DownloadPage.tsx +++ b/src/pages/DownloadPage.tsx @@ -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<{ + )} + {isRunning && ( + + )} + {(isPaused || entry.status === 'failed') && ( + + )} + {(isRunning || isPaused || isQueued) && ( + + )} + + + + + {!isDone && ( + <> +
+
+
+
+ {formatBytes(downloaded)} / {total > 0 ? formatBytes(total) : '?'} + {pct}% + {isRunning && ( + <> + {formatSpeed(speed)} + {etaSec > 0 && {t('downloads.etaPrefix')} {formatEta(etaSec)}} + + )} + {entry.status === 'failed' && entry.errorMessage && ( + {entry.errorMessage} + )} +
+ + )} + + {pendingDelete && ( +
+

{t('downloads.deleteConfirmTitle')}

+
+ + +
+
+ )} +
+ ); +}; + +const TypeIcon: React.FC<{ type: MovixDownloadType | 'misc' }> = ({ type }) => { + if (type === 'movie') return ; + if (type === 'series') return ; + if (type === 'animes') return ; + return ; +}; + +const DownloadsPage: React.FC = () => { + const { t } = useTranslation(); + const inApp = isMovixApp(); + const [entries, setEntries] = useState([]); + const [ticks, setTicks] = useState>({}); + 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 ( + +
+ + + {t('downloads.backToHome')} + +
+ + +

{t('downloads.appOnlyTitle')}

+

{t('downloads.appOnlyDesc')}

+
+
+
+
+ ); + } + + 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 = {}; + items.forEach((e) => { + const k = String(e.metadata?.tmdbId ?? e.metadata?.title ?? 'unknown'); + (byTitle[k] ??= []).push(e); + }); + return ( + +
+ +

{t(`downloads.group.${kind}`)}

+ {items.length} +
+ {Object.entries(byTitle).map(([titleKey, group]) => { + const repr = group[0]; + const bySeason: Record = {}; + 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 ( + +
+ {repr.metadata?.poster && ( + { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + )} +
+

{repr.metadata?.title || t('downloads.unknownTitle')}

+

{group.length} {t('downloads.fileCountSuffix')}

+
+
+ {seasonKeys.map((s) => ( +
+

+ {s === '?' ? t('downloads.unknownSeason') : t('downloads.seasonLabel', { n: s })} +

+
+ {bySeason[s] + .sort((a, b) => (Number(a.metadata?.episode ?? 0) - Number(b.metadata?.episode ?? 0))) + .map((entry) => ( + + ))} +
+
+ ))} +
+ ); + })} +
+ ); + } + + // Movies + misc : plat. + return ( + +
+ +

{t(`downloads.group.${kind}`)}

+ {items.length} +
+
+ {items + .sort((a, b) => b.createdAt - a.createdAt) + .map((entry) => ( + + ))} +
+
+ ); + }; + + return ( + +
+ + + {t('downloads.backToHome')} + + +
+
+
+

{t('downloads.title')}

+

{t('downloads.subtitle')}

+
+
+

{t('downloads.activeLabel')}

+

{activeCount}

+
+
+ + {loading ? ( +
+ {t('downloads.loading')} +
+ ) : entries.length === 0 ? ( + + +

{t('downloads.empty')}

+

{t('downloads.emptyHint')}

+
+ ) : ( + +
+ {renderGroup('movie', grouped.movie)} + {renderGroup('series', grouped.series)} + {renderGroup('animes', grouped.animes)} + {renderGroup('misc', grouped.misc)} +
+
+ )} + + {entries.some((e) => e.status === 'failed') && ( +
+ + {t('downloads.failedHint')} +
+ )} +
+
+
+ ); +}; + +export default DownloadsPage; diff --git a/src/routing/registry.tsx b/src/routing/registry.tsx index 9337152..70d9088 100644 --- a/src/routing/registry.tsx +++ b/src/routing/registry.tsx @@ -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')) }, diff --git a/src/utils/appBridge.ts b/src/utils/appBridge.ts new file mode 100644 index 0000000..b0b0fcc --- /dev/null +++ b/src/utils/appBridge.ts @@ -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; + 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; +} + +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; + resume: (id: string) => Promise; + cancel: (id: string) => Promise; + delete: (id: string) => Promise; + launch: (id: string) => Promise; + list: () => Promise; + get: (id: string) => Promise; + 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'; +}