mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-08-04 00:35:56 +00:00
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:
parent
2045dbd2c9
commit
7a51ec71d9
24 changed files with 2101 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() ?: "{}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) } }
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
0
app/android/gradlew
vendored
Normal file → Executable 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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
161
app/src/services/downloadBridge.ts
Normal file
161
app/src/services/downloadBridge.ts
Normal 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());
|
||||
};
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
487
src/pages/DownloadsPage.tsx
Normal 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;
|
||||
|
|
@ -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
122
src/utils/appBridge.ts
Normal 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';
|
||||
}
|
||||
Loading…
Reference in a new issue