downloads not working!!

This commit is contained in:
LagradOst 2021-07-04 19:00:04 +02:00
parent 3210e71eca
commit 728874fc03
8 changed files with 340 additions and 179 deletions

View file

@ -33,6 +33,12 @@
</intent-filter>
</activity>
<service
android:name=".services.VideoDownloadService"
android:enabled="true"
android:exported="false">
</service>
<activity android:name=".ui.ControllerActivity">
</activity>

View file

@ -1,16 +1,24 @@
package com.lagradost.cloudstream3
import android.Manifest
import android.app.PictureInPictureParams
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.documentfile.provider.DocumentFile
import androidx.navigation.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.setupWithNavController
import com.anggrayudi.storage.SimpleStorage
import com.anggrayudi.storage.callback.StorageAccessCallback
import com.anggrayudi.storage.file.StorageId
import com.anggrayudi.storage.file.StorageType
import com.anggrayudi.storage.file.getStorageId
import com.google.android.gms.cast.ApplicationMetadata
import com.google.android.gms.cast.Cast
import com.google.android.gms.cast.LaunchOptions
@ -19,6 +27,9 @@ import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastSession
import com.google.android.gms.cast.framework.SessionManagerListener
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.karumi.dexter.Dexter
import com.karumi.dexter.MultiplePermissionsReport
import com.karumi.dexter.listener.multi.BaseMultiplePermissionsListener
import com.lagradost.cloudstream3.UIHelper.checkWrite
import com.lagradost.cloudstream3.UIHelper.hasPIPPermission
import com.lagradost.cloudstream3.UIHelper.requestRW
@ -118,7 +129,10 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
mainContext = this
setupSimpleStorage()
storage.requestStorageAccess(REQUEST_CODE_STORAGE_ACCESS)
if(!storage.isStorageAccessGranted(StorageId.PRIMARY)) {
storage.requestStorageAccess(REQUEST_CODE_STORAGE_ACCESS)
}
setContentView(R.layout.activity_main)
val navView: BottomNavigationView = findViewById(R.id.nav_view)

View file

@ -0,0 +1,23 @@
package com.lagradost.cloudstream3.services
import android.app.IntentService
import android.content.Intent
import com.lagradost.cloudstream3.utils.VideoDownloadManager
class VideoDownloadService : IntentService("VideoDownloadService") {
override fun onHandleIntent(intent: Intent?) {
if (intent != null) {
val id = intent.getIntExtra("id", -1)
val type = intent.getStringExtra("type")
if (id != -1 && type != null) {
val state = when (type) {
"resume" -> VideoDownloadManager.DownloadActionType.Resume
"pause" -> VideoDownloadManager.DownloadActionType.Pause
"stop" -> VideoDownloadManager.DownloadActionType.Stop
else -> return
}
VideoDownloadManager.events.invoke(Pair(id, state))
}
}
}
}

View file

@ -48,6 +48,7 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.VideoDownloadManager
import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
import jp.wasabeef.glide.transformations.BlurTransformation
import kotlinx.android.synthetic.main.fragment_result.*
@ -343,10 +344,10 @@ class ResultFragment : Fragment() {
viewModel.loadEpisode(episodeClick.data, true) { data ->
if (data is Resource.Success) {
val isMovie = currentIsMovie ?: return@loadEpisode
val titleName = currentHeaderName?: return@loadEpisode
val titleName = sanitizeFilename(currentHeaderName ?: return@loadEpisode)
val meta = VideoDownloadManager.DownloadEpisodeMetadata(
episodeClick.data.id,
titleName ,
titleName,
apiName ?: return@loadEpisode,
episodeClick.data.poster ?: currentPoster,
episodeClick.data.name,
@ -362,7 +363,7 @@ class ResultFragment : Fragment() {
else -> null
}
VideoDownloadManager.DownloadEpisode(
VideoDownloadManager.downloadEpisode(
requireContext(),
tempUrl,
folder,

View file

@ -2,11 +2,12 @@ package com.lagradost.cloudstream3.utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object Coroutines {
fun main(work: suspend (() -> Unit)) {
CoroutineScope(Dispatchers.Main).launch {
fun main(work: suspend (() -> Unit)) : Job {
return CoroutineScope(Dispatchers.Main).launch {
work()
}
}

View file

@ -7,25 +7,33 @@ import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.os.Build
import android.os.Environment
import androidx.annotation.DrawableRes
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
import com.anggrayudi.storage.extension.closeStream
import com.anggrayudi.storage.file.DocumentFileCompat
import com.anggrayudi.storage.file.forceDelete
import com.anggrayudi.storage.file.openOutputStream
import com.bumptech.glide.Glide
import com.google.android.exoplayer2.offline.DownloadService
import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.services.VideoDownloadService
import com.lagradost.cloudstream3.utils.Coroutines.main
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.BufferedInputStream
import java.io.InputStream
import java.lang.Thread.sleep
import java.net.URL
import java.net.URLConnection
import java.util.*
import kotlin.collections.ArrayList
const val CHANNEL_ID = "cloudstream3.general"
const val CHANNEL_NAME = "Downloads"
@ -33,6 +41,7 @@ const val CHANNEL_DESCRIPT = "The download notification channel"
object VideoDownloadManager {
var maxConcurrentDownloads = 3
var currentDownloads = 0
private const val USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
@ -85,6 +94,26 @@ object VideoDownloadManager {
val episode: Int?
)
data class DownloadItem(
val source: String,
val folder: String?,
val ep: DownloadEpisodeMetadata,
val links: List<ExtractorLink>
)
private const val SUCCESS_DOWNLOAD_DONE = 1
private const val SUCCESS_STOPPED = 2
private const val ERROR_DELETING_FILE = -1
private const val ERROR_FILE_NOT_FOUND = -2
private const val ERROR_OPEN_FILE = -3
private const val ERROR_TOO_SMALL_CONNECTION = -4
private const val ERROR_WRONG_CONTENT = -5
private const val ERROR_CONNECTION_ERROR = -6
val events = Event<Pair<Int, DownloadActionType>>()
private val downloadQueue = LinkedList<DownloadItem>()
private var hasCreatedNotChanel = false
private fun Context.createNotificationChannel() {
hasCreatedNotChanel = true
@ -129,63 +158,80 @@ object VideoDownloadManager {
progress: Long,
total: Long,
) {
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setAutoCancel(true)
.setColorized(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setColor(context.colorFromAttribute(R.attr.colorPrimary))
.setContentTitle(ep.mainName)
.setSmallIcon(
when (state) {
DownloadType.IsDone -> imgDone
DownloadType.IsDownloading -> imgDownloading
DownloadType.IsPaused -> imgPaused
DownloadType.IsFailed -> imgError
DownloadType.IsStopped -> imgStopped
main { // DON'T WANT TO SLOW IT DOWN
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setAutoCancel(true)
.setColorized(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setColor(context.colorFromAttribute(R.attr.colorPrimary))
.setContentTitle(ep.mainName)
.setSmallIcon(
when (state) {
DownloadType.IsDone -> imgDone
DownloadType.IsDownloading -> imgDownloading
DownloadType.IsPaused -> imgPaused
DownloadType.IsFailed -> imgError
DownloadType.IsStopped -> imgStopped
}
)
if (ep.sourceApiName != null) {
builder.setSubText(ep.sourceApiName)
}
if (source != null) {
val intent = Intent(context, MainActivity::class.java).apply {
data = source.toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
)
if (ep.sourceApiName != null) {
builder.setSubText(ep.sourceApiName)
}
if (source != null) {
val intent = Intent(context, MainActivity::class.java).apply {
data = source.toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
builder.setContentIntent(pendingIntent)
}
if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) {
builder.setProgress(total.toInt(), progress.toInt(), false)
}
val rowTwoExtra = if (ep.name != null) " - ${ep.name}\n" else ""
val rowTwo = if (ep.season != null && ep.episode != null) {
"S${ep.season}:E${ep.episode}" + rowTwoExtra
} else if (ep.episode != null) {
"Episode ${ep.episode}" + rowTwoExtra
} else {
(ep.name ?: "") + ""
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (ep.poster != null) {
val poster = context.getImageBitmapFromUrl(ep.poster)
if (poster != null)
builder.setLargeIcon(poster)
val pendingIntent: PendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
builder.setContentIntent(pendingIntent)
}
val progressPercentage = progress * 100 / total
val progressMbString = "%.1f".format(progress / 1000000f)
val totalMbString = "%.1f".format(total / 1000000f)
if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) {
builder.setProgress(total.toInt(), progress.toInt(), false)
}
val bigText =
if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) {
(if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString MB/$totalMbString MB)"
val rowTwoExtra = if (ep.name != null) " - ${ep.name}\n" else ""
val rowTwo = if (ep.season != null && ep.episode != null) {
"S${ep.season}:E${ep.episode}" + rowTwoExtra
} else if (ep.episode != null) {
"Episode ${ep.episode}" + rowTwoExtra
} else {
(ep.name ?: "") + ""
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (ep.poster != null) {
val poster = withContext(Dispatchers.IO) {
context.getImageBitmapFromUrl(ep.poster)
}
if (poster != null)
builder.setLargeIcon(poster)
}
val progressPercentage = progress * 100 / total
val progressMbString = "%.1f".format(progress / 1000000f)
val totalMbString = "%.1f".format(total / 1000000f)
val bigText =
if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) {
(if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString MB/$totalMbString MB)"
} else if (state == DownloadType.IsFailed) {
"Download Failed - $rowTwo"
} else if (state == DownloadType.IsDone) {
"Download Done - $rowTwo"
} else {
"Download Stopped - $rowTwo"
}
val bodyStyle = NotificationCompat.BigTextStyle()
bodyStyle.bigText(bigText)
builder.setStyle(bodyStyle)
} else {
val txt = if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) {
rowTwo
} else if (state == DownloadType.IsFailed) {
"Download Failed - $rowTwo"
} else if (state == DownloadType.IsDone) {
@ -194,103 +240,99 @@ object VideoDownloadManager {
"Download Stopped - $rowTwo"
}
val bodyStyle = NotificationCompat.BigTextStyle()
bodyStyle.bigText(bigText)
builder.setStyle(bodyStyle)
} else {
val txt = if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) {
rowTwo
} else if (state == DownloadType.IsFailed) {
"Download Failed - $rowTwo"
} else if (state == DownloadType.IsDone) {
"Download Done - $rowTwo"
} else {
"Download Stopped - $rowTwo"
builder.setContentText(txt)
}
builder.setContentText(txt)
}
if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val actionTypes: MutableList<DownloadActionType> = ArrayList()
// INIT
if (state == DownloadType.IsDownloading) {
actionTypes.add(DownloadActionType.Pause)
actionTypes.add(DownloadActionType.Stop)
}
if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val actionTypes: MutableList<DownloadActionType> = ArrayList()
// INIT
if (state == DownloadType.IsDownloading) {
actionTypes.add(DownloadActionType.Pause)
actionTypes.add(DownloadActionType.Stop)
}
if (state == DownloadType.IsPaused) {
actionTypes.add(DownloadActionType.Resume)
actionTypes.add(DownloadActionType.Stop)
}
if (state == DownloadType.IsPaused) {
actionTypes.add(DownloadActionType.Resume)
actionTypes.add(DownloadActionType.Stop)
}
// ADD ACTIONS
for ((index, i) in actionTypes.withIndex()) {
val actionResultIntent = Intent(context, VideoDownloadService::class.java)
// ADD ACTIONS
for ((index, i) in actionTypes.withIndex()) {
val actionResultIntent = Intent(context, DownloadService::class.java)
actionResultIntent.putExtra(
"type", when (i) {
DownloadActionType.Resume -> "resume"
DownloadActionType.Pause -> "pause"
DownloadActionType.Stop -> "stop"
}
)
actionResultIntent.putExtra("id", ep.id)
val pending: PendingIntent = PendingIntent.getService(
context, 4337 + index + ep.id,
actionResultIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
builder.addAction(
NotificationCompat.Action(
when (i) {
DownloadActionType.Resume -> pressToResumeIcon
DownloadActionType.Pause -> pressToPauseIcon
DownloadActionType.Stop -> pressToStopIcon
}, when (i) {
DownloadActionType.Resume -> "Resume"
DownloadActionType.Pause -> "Pause"
DownloadActionType.Stop -> "Stop"
}, pending
actionResultIntent.putExtra(
"type", when (i) {
DownloadActionType.Resume -> "resume"
DownloadActionType.Pause -> "pause"
DownloadActionType.Stop -> "stop"
}
)
)
actionResultIntent.putExtra("id", ep.id)
val pending: PendingIntent = PendingIntent.getService(
context, 4337 + index + ep.id,
actionResultIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
builder.addAction(
NotificationCompat.Action(
when (i) {
DownloadActionType.Resume -> pressToResumeIcon
DownloadActionType.Pause -> pressToPauseIcon
DownloadActionType.Stop -> pressToStopIcon
}, when (i) {
DownloadActionType.Resume -> "Resume"
DownloadActionType.Pause -> "Pause"
DownloadActionType.Stop -> "Stop"
}, pending
)
)
}
}
}
if (!hasCreatedNotChanel) {
context.createNotificationChannel()
}
if (!hasCreatedNotChanel) {
context.createNotificationChannel()
}
with(NotificationManagerCompat.from(context)) {
// notificationId is a unique int for each notification that you must define
notify(ep.id, builder.build())
with(NotificationManagerCompat.from(context)) {
// notificationId is a unique int for each notification that you must define
notify(ep.id, builder.build())
}
}
}
private const val reservedChars = "|\\?*<\":>+[]/'"
private fun sanitizeFilename(name: String): String {
private const val reservedChars = "|\\?*<\":>+[]/\'"
fun sanitizeFilename(name: String): String {
var tempName = name
for (c in reservedChars) {
tempName = tempName.replace(c, ' ')
}
return tempName.replace(" ", " ")
return tempName.replace(" ", " ").trim(' ')
}
fun DownloadSingleEpisode(
private const val reservedCharsPath = "|\\?*<\":>+[]\'"
fun sanitizePath(name: String): String {
var tempName = name
for (c in reservedCharsPath) {
tempName = tempName.replace(c, ' ')
}
return tempName.replace(" ", " ").trim(' ')
}
private fun downloadSingleEpisode(
context: Context,
source: String?,
folder: String?,
ep: DownloadEpisodeMetadata,
link: ExtractorLink
): Boolean {
val name = (ep.name ?: "Episode ${ep.episode}")
val path = sanitizeFilename("Download/${if (folder == null) "" else "$folder/"}$name")
): Int {
val name = sanitizeFilename(ep.name ?: "Episode ${ep.episode}")
val path = "${Environment.DIRECTORY_DOWNLOADS}/${if (folder == null) "" else "$folder/"}$name.mp4"
var resume = false
// IF RESUME, DELETE FILE IF FOUND AND RECREATE
// IF RESUME, DON'T DELETE FILE, CONTINUE, RECREATE IF NOT FOUND
// IF NOT RESUME CREATE FILE
val tempFile = DocumentFileCompat.fromSimplePath(context, basePath = path)
val fileExists = tempFile?.exists() ?: false
@ -298,7 +340,7 @@ object VideoDownloadManager {
if (!fileExists) resume = false
if (fileExists && !resume) {
if (tempFile?.delete() == false) { // DELETE FAILED ON RESUME FILE
return false
return ERROR_DELETING_FILE
}
}
@ -308,13 +350,12 @@ object VideoDownloadManager {
// END OF FILE CREATION
if (dFile == null) {
println("FUCK YOU")
return false
if (dFile == null || !dFile.exists()) {
return ERROR_FILE_NOT_FOUND
}
// OPEN FILE
val fileStream = dFile.openOutputStream(context, resume) ?: return false
val fileStream = dFile.openOutputStream(context, resume) ?: return ERROR_OPEN_FILE
// CONNECT
val connection: URLConnection = URL(link.url).openConnection()
@ -331,15 +372,16 @@ object VideoDownloadManager {
connection.connect()
val contentLength = connection.contentLength
val bytesTotal = contentLength + resumeLength
if (bytesTotal < 5000000) return false // DATA IS LESS THAN 5MB, SOMETHING IS WRONG
if (bytesTotal < 5000000) return ERROR_TOO_SMALL_CONNECTION // DATA IS LESS THAN 5MB, SOMETHING IS WRONG
// Could use connection.contentType for mime types when creating the file,
// however file is already created and players don't go of file type
// https://stackoverflow.com/questions/23714383/what-are-all-the-possible-values-for-http-content-type-header
if(!connection.contentType.isNullOrEmpty() && !connection.contentType.startsWith("video")) {
return false // CONTENT IS NOT VIDEO, SHOULD NEVER HAPPENED, BUT JUST IN CASE
}
// might receive application/octet-stream
/*if (!connection.contentType.isNullOrEmpty() && !connection.contentType.startsWith("video")) {
return ERROR_WRONG_CONTENT // CONTENT IS NOT VIDEO, SHOULD NEVER HAPPENED, BUT JUST IN CASE
}*/
// READ DATA FROM CONNECTION
val connectionInputStream: InputStream = BufferedInputStream(connection.inputStream)
@ -347,8 +389,21 @@ object VideoDownloadManager {
var count: Int
var bytesDownloaded = resumeLength
var isPaused = false
var isStopped = false
var isDone = false
var isFailed = false
// TO NOT REUSE CODE
fun updateNotification(type: DownloadType) {
fun updateNotification() {
val type = when {
isDone -> DownloadType.IsDone
isStopped -> DownloadType.IsStopped
isFailed -> DownloadType.IsFailed
isPaused -> DownloadType.IsPaused
else -> DownloadType.IsDownloading
}
createNotification(
context,
source,
@ -360,24 +415,108 @@ object VideoDownloadManager {
)
}
while (true) { // TODO PAUSE
count = connectionInputStream.read(buffer)
if (count < 0) break
bytesDownloaded += count
updateNotification(DownloadType.IsDownloading)
fileStream.write(buffer, 0, count)
fun onEvent(event: Pair<Int, DownloadActionType>) {
if (event.first == ep.id) {
when (event.second) {
DownloadActionType.Pause -> {
isPaused = true; updateNotification()
}
DownloadActionType.Stop -> {
isStopped = true; updateNotification()
}
DownloadActionType.Resume -> {
isPaused = false; updateNotification()
}
}
}
}
// DOWNLOAD EXITED CORRECTLY
updateNotification(DownloadType.IsDone)
events += ::onEvent
// UPDATE DOWNLOAD NOTIFICATION
val notificationCoroutine = main {
while (true) {
if (!isPaused) {
updateNotification()
}
for (i in 1..10) {
delay(100)
}
}
}
// THE REAL READ
try {
while (true) {
count = connectionInputStream.read(buffer)
if (count < 0) break
bytesDownloaded += count
while (isPaused) {
sleep(100)
if (isStopped) {
break
}
}
if (isStopped) {
break
}
fileStream.write(buffer, 0, count)
}
} catch (e: Exception) {
isFailed = true
updateNotification()
}
// REMOVE AND EXIT ALL
events -= ::onEvent
fileStream.closeStream()
connectionInputStream.closeStream()
notificationCoroutine.cancel()
return true
// RETURN MESSAGE
return when {
isFailed -> {
ERROR_CONNECTION_ERROR
}
isStopped -> {
dFile.delete()
SUCCESS_STOPPED
}
else -> {
isDone = true
updateNotification()
SUCCESS_DOWNLOAD_DONE
}
}
}
public fun DownloadEpisode(
private fun downloadCheck(context: Context) {
if (currentDownloads < maxConcurrentDownloads && downloadQueue.size > 0) {
val item = downloadQueue.removeFirst()
currentDownloads++
try {
main {
for (link in item.links) {
val connectionResult = withContext(Dispatchers.IO) {
normalSafeApiCall {
downloadSingleEpisode(context, item.source, item.folder, item.ep, link)
}
}
if (connectionResult != null && connectionResult > 0) { // SUCCESS
break
}
}
}
} catch (e: Exception) {
logError(e)
} finally {
currentDownloads--
downloadCheck(context)
}
}
}
fun downloadEpisode(
context: Context,
source: String,
folder: String?,
@ -386,16 +525,8 @@ object VideoDownloadManager {
) {
val validLinks = links.filter { !it.isM3u8 }
if (validLinks.isNotEmpty()) {
try {
main {
withContext(Dispatchers.IO) {
DownloadSingleEpisode(context, source, folder, ep, validLinks.first())
}
}
} catch (e: Exception) {
println(e)
e.printStackTrace()
}
downloadQueue.addLast(DownloadItem(source, folder, ep, validLinks))
downloadCheck(context)
}
}
}

View file

@ -1,16 +0,0 @@
package com.lagradost.cloudstream3.utils
import android.app.IntentService
import android.content.Intent
class VideoDownloadService : IntentService("DownloadService") {
override fun onHandleIntent(intent: Intent?) {
if (intent != null) {
val id = intent.getIntExtra("id", -1)
val type = intent.getStringExtra("type")
if (id != -1 && type != null) {
}
}
}
}

View file

@ -37,4 +37,5 @@
<string name="result_go_back">Go Back</string>
<string name="episode_poster">Episode Poster</string>
<string name="play_episode">Play Episode</string>
<string name="need_storage">Allow to download episodes</string>
</resources>