Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93f4c4c982 | ||
|
|
cb8f5d0e4e | ||
|
|
8edf5c893e |
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "com.iptv.app"
|
applicationId = "com.iptv.app"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 1
|
versionCode = 10002
|
||||||
versionName = "1.0"
|
versionName = "1.0.2"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".IPTVApplication"
|
android:name=".IPTVApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
android:icon="@android:drawable/ic_media_play"
|
android:icon="@android:drawable/ic_media_play"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ class UpdateService(context: Context) {
|
|||||||
private const val REPO_OWNER = "renato97"
|
private const val REPO_OWNER = "renato97"
|
||||||
private const val REPO_NAME = "iptv-app"
|
private const val REPO_NAME = "iptv-app"
|
||||||
|
|
||||||
|
// Token de Gitea para acceder a releases privados
|
||||||
|
private const val GITEA_TOKEN = "efeed2af00597883adb04da70bd6a7c2993ae92d"
|
||||||
|
|
||||||
// Endpoints
|
// Endpoints
|
||||||
private const val LATEST_RELEASE_ENDPOINT = "$GITEA_API_URL/repos/$REPO_OWNER/$REPO_NAME/releases/latest"
|
private const val LATEST_RELEASE_ENDPOINT = "$GITEA_API_URL/repos/$REPO_OWNER/$REPO_NAME/releases/latest"
|
||||||
|
|
||||||
@@ -70,6 +73,7 @@ class UpdateService(context: Context) {
|
|||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(LATEST_RELEASE_ENDPOINT)
|
.url(LATEST_RELEASE_ENDPOINT)
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
|
.header("Authorization", "token $GITEA_TOKEN")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
@@ -124,8 +128,17 @@ class UpdateService(context: Context) {
|
|||||||
*/
|
*/
|
||||||
fun downloadUpdate(updateInfo: UpdateInfo, outputDir: File): Flow<DownloadProgress> = flow {
|
fun downloadUpdate(updateInfo: UpdateInfo, outputDir: File): Flow<DownloadProgress> = flow {
|
||||||
try {
|
try {
|
||||||
|
// Construir URL de descarga con autenticación para repos privados
|
||||||
|
val downloadUrl = if (updateInfo.downloadUrl.contains("/attachments/")) {
|
||||||
|
// Para Gitea, usar API con token
|
||||||
|
"$GITEA_API_URL/repos/$REPO_OWNER/$REPO_NAME/releases/attachments/${updateInfo.fileName}"
|
||||||
|
} else {
|
||||||
|
updateInfo.downloadUrl
|
||||||
|
}
|
||||||
|
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(updateInfo.downloadUrl)
|
.url(downloadUrl)
|
||||||
|
.header("Authorization", "token $GITEA_TOKEN")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
|
|||||||
@@ -107,6 +107,38 @@ class ChannelRepository(
|
|||||||
}
|
}
|
||||||
.flowOn(Dispatchers.Default)
|
.flowOn(Dispatchers.Default)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns sports channels for testing purposes.
|
||||||
|
*/
|
||||||
|
fun getSportsChannels(): Flow<List<Channel>> = _allChannels
|
||||||
|
.map { channels ->
|
||||||
|
channels.filter { channel ->
|
||||||
|
val category = channel.category.lowercase()
|
||||||
|
val name = channel.name.lowercase()
|
||||||
|
category.contains("sport") ||
|
||||||
|
category.contains("deport") ||
|
||||||
|
name.contains("espn") ||
|
||||||
|
name.contains("fox sport") ||
|
||||||
|
name.contains("bein") ||
|
||||||
|
name.contains("sky sport") ||
|
||||||
|
name.contains("bt sport") ||
|
||||||
|
name.contains("eurosport") ||
|
||||||
|
name.contains("tsn") ||
|
||||||
|
name.contains("nba") ||
|
||||||
|
name.contains("nfl") ||
|
||||||
|
name.contains("mlb") ||
|
||||||
|
name.contains("ufc") ||
|
||||||
|
name.contains("wwe") ||
|
||||||
|
name.contains("golf") ||
|
||||||
|
name.contains("tennis") ||
|
||||||
|
name.contains("f1") ||
|
||||||
|
name.contains("formula 1") ||
|
||||||
|
name.contains("racing") ||
|
||||||
|
name.contains("motorsport")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.flowOn(Dispatchers.Default)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Searches channels by name with debouncing.
|
* Searches channels by name with debouncing.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource
|
|||||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
|
import android.widget.FrameLayout
|
||||||
import com.iptv.app.utils.DnsConfigurator
|
import com.iptv.app.utils.DnsConfigurator
|
||||||
import com.iptv.app.utils.PlayerManager
|
import com.iptv.app.utils.PlayerManager
|
||||||
|
|
||||||
@@ -200,15 +201,16 @@ private fun createExoPlayer(
|
|||||||
val trackSelector = DefaultTrackSelector(context).apply {
|
val trackSelector = DefaultTrackSelector(context).apply {
|
||||||
setParameters(
|
setParameters(
|
||||||
this.buildUponParameters()
|
this.buildUponParameters()
|
||||||
.setMaxVideoSizeSd()
|
|
||||||
.setPreferredAudioLanguage("en")
|
.setPreferredAudioLanguage("en")
|
||||||
|
.setSelectUndeterminedTextLanguage(false)
|
||||||
|
.setPreferredTextLanguage(null)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val loadControl = DefaultLoadControl.Builder()
|
val loadControl = DefaultLoadControl.Builder()
|
||||||
.setBufferDurationsMs(
|
.setBufferDurationsMs(
|
||||||
DefaultLoadControl.DEFAULT_MIN_BUFFER_MS,
|
DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * 2,
|
||||||
DefaultLoadControl.DEFAULT_MAX_BUFFER_MS,
|
DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * 2,
|
||||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
|
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
|
||||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
|
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
|
||||||
)
|
)
|
||||||
@@ -220,15 +222,16 @@ private fun createExoPlayer(
|
|||||||
.setMediaSourceFactory(DefaultMediaSourceFactory(context))
|
.setMediaSourceFactory(DefaultMediaSourceFactory(context))
|
||||||
.setAudioAttributes(playerManager.getAudioAttributes(), true)
|
.setAudioAttributes(playerManager.getAudioAttributes(), true)
|
||||||
.setHandleAudioBecomingNoisy(true)
|
.setHandleAudioBecomingNoisy(true)
|
||||||
|
.setVideoScalingMode(C.VIDEO_SCALING_MODE_SCALE_TO_FIT)
|
||||||
.build()
|
.build()
|
||||||
.apply {
|
.apply {
|
||||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
|
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
|
||||||
repeatMode = Player.REPEAT_MODE_OFF
|
repeatMode = Player.REPEAT_MODE_OFF
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the PlayerView for displaying video
|
* Creates the PlayerView for displaying video using SurfaceView for best performance
|
||||||
*/
|
*/
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private fun createPlayerView(context: Context, player: ExoPlayer): PlayerView {
|
private fun createPlayerView(context: Context, player: ExoPlayer): PlayerView {
|
||||||
@@ -236,10 +239,13 @@ private fun createPlayerView(context: Context, player: ExoPlayer): PlayerView {
|
|||||||
this.player = player
|
this.player = player
|
||||||
useController = false // We use custom controls
|
useController = false // We use custom controls
|
||||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
setBackgroundColor(android.graphics.Color.BLACK)
|
||||||
|
|
||||||
|
layoutParams = FrameLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
)
|
)
|
||||||
|
|
||||||
setKeepContentOnPlayerReset(true)
|
setKeepContentOnPlayerReset(true)
|
||||||
setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER)
|
setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER)
|
||||||
}
|
}
|
||||||
@@ -323,7 +329,6 @@ private fun createPlayerListener(
|
|||||||
private fun preparePlayer(context: Context, player: ExoPlayer, streamUrl: String) {
|
private fun preparePlayer(context: Context, player: ExoPlayer, streamUrl: String) {
|
||||||
val mediaItem = MediaItem.Builder()
|
val mediaItem = MediaItem.Builder()
|
||||||
.setUri(streamUrl)
|
.setUri(streamUrl)
|
||||||
.setMimeType("application/vnd.apple.mpegurl")
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// Create OkHttpClient with Google DNS configuration
|
// Create OkHttpClient with Google DNS configuration
|
||||||
@@ -332,10 +337,10 @@ private fun preparePlayer(context: Context, player: ExoPlayer, streamUrl: String
|
|||||||
// Create OkHttpDataSource.Factory with custom DNS client
|
// Create OkHttpDataSource.Factory with custom DNS client
|
||||||
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
|
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
|
||||||
|
|
||||||
val mediaSource = HlsMediaSource.Factory(dataSourceFactory)
|
val mediaSourceFactory = DefaultMediaSourceFactory(context)
|
||||||
.createMediaSource(mediaItem)
|
.setDataSourceFactory(dataSourceFactory)
|
||||||
|
|
||||||
player.setMediaSource(mediaSource)
|
player.setMediaSource(mediaSourceFactory.createMediaSource(mediaItem))
|
||||||
player.prepare()
|
player.prepare()
|
||||||
player.playWhenReady = true
|
player.playWhenReady = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,9 +58,13 @@ fun PlayerScreen(
|
|||||||
val activity = context as? Activity
|
val activity = context as? Activity
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
// ExoPlayer setup
|
// ExoPlayer setup with proper video configuration
|
||||||
val exoPlayer = remember {
|
val exoPlayer = remember {
|
||||||
ExoPlayer.Builder(context).build().apply {
|
ExoPlayer.Builder(context)
|
||||||
|
.setVideoScalingMode(androidx.media3.common.C.VIDEO_SCALING_MODE_SCALE_TO_FIT)
|
||||||
|
.build()
|
||||||
|
.apply {
|
||||||
|
videoScalingMode = androidx.media3.common.C.VIDEO_SCALING_MODE_SCALE_TO_FIT
|
||||||
setMediaItem(MediaItem.fromUri(channel.streamUrl))
|
setMediaItem(MediaItem.fromUri(channel.streamUrl))
|
||||||
prepare()
|
prepare()
|
||||||
playWhenReady = true
|
playWhenReady = true
|
||||||
@@ -169,6 +173,8 @@ fun PlayerScreen(
|
|||||||
player = exoPlayer
|
player = exoPlayer
|
||||||
useController = false
|
useController = false
|
||||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
|
setBackgroundColor(android.graphics.Color.BLACK)
|
||||||
|
setKeepContentOnPlayerReset(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
|
|||||||
@@ -65,6 +65,37 @@ class ChannelsViewModel(
|
|||||||
setupSearchAndFilter()
|
setupSearchAndFilter()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets sports channels for testing video playback.
|
||||||
|
*/
|
||||||
|
fun getSportsChannels(): Flow<List<Channel>> {
|
||||||
|
return channelRepository.getSportsChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows only sports channels in the UI for testing.
|
||||||
|
*/
|
||||||
|
fun showSportsChannelsOnly() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
channelRepository.getSportsChannels()
|
||||||
|
.onEach { sportsChannels ->
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(
|
||||||
|
channels = sportsChannels,
|
||||||
|
filteredChannels = sportsChannels,
|
||||||
|
selectedCategory = "Sports"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.catch { error ->
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.copy(errorMessage = "Error loading sports channels: ${error.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads channels from the repository.
|
* Loads channels from the repository.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ class PlayerManager(private val context: Context) {
|
|||||||
trackSelector = DefaultTrackSelector(context).apply {
|
trackSelector = DefaultTrackSelector(context).apply {
|
||||||
val params = this.parameters.buildUpon()
|
val params = this.parameters.buildUpon()
|
||||||
.setPreferredAudioLanguage("en")
|
.setPreferredAudioLanguage("en")
|
||||||
.setMaxVideoSizeSd()
|
|
||||||
.build()
|
.build()
|
||||||
this.parameters = params
|
this.parameters = params
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user