Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf11aa04bc | ||
| d2d66a7906 | |||
| 5ade6350eb | |||
| 96c4c360ee | |||
| ab43ce7794 | |||
| 24a4c93fb5 | |||
| eba119493c | |||
| 87780cddee | |||
| 672774e216 |
42
README.md
42
README.md
@@ -82,6 +82,44 @@ chmod +x build_apk.sh
|
|||||||
./build_apk.sh
|
./build_apk.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 🔄 Control de Instalaciones y Actualizaciones
|
||||||
|
|
||||||
|
StreamPlayer ahora consulta automáticamente las releases públicas del repositorio Gitea y puede forzar o sugerir actualizaciones directamente desde la app. Para aprovecharlo:
|
||||||
|
|
||||||
|
1. Ejecuta un build nuevo incrementando `versionCode`/`versionName` en `app/build.gradle`.
|
||||||
|
2. Crea una release en Gitea asignando un tag como `v9.1` y sube el APK.
|
||||||
|
3. Adjunta un archivo `update-manifest.json` en la misma release (puedes partir de `release/update-manifest.example.json`).
|
||||||
|
|
||||||
|
### Formato de `update-manifest.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"versionCode": 91000,
|
||||||
|
"versionName": "9.1.0",
|
||||||
|
"minSupportedVersionCode": 90000,
|
||||||
|
"forceUpdate": false,
|
||||||
|
"downloadUrl": "https://gitea.cbcren.online/renato97/app/releases/download/v9.1/StreamPlayer-v9.1.apk",
|
||||||
|
"fileName": "StreamPlayer-v9.1.apk",
|
||||||
|
"sizeBytes": 12345678,
|
||||||
|
"notes": "Novedades destacadas que aparecerán en el diálogo dentro de la app"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `versionCode` / `versionName`: deben coincidir con el APK publicado.
|
||||||
|
- `minSupportedVersionCode`: define desde qué versión mínima se permite seguir usando la app (ideal para bloquear instalaciones antiguas).
|
||||||
|
- `forceUpdate`: si es `true` la app mostrará un diálogo sin opción de omitir.
|
||||||
|
- `downloadUrl` / `fileName`: apuntan al asset `.apk` publicado en Gitea (puede ser el enlace de descarga directo).
|
||||||
|
- `notes`: texto libre mostrado en el diálogo dentro de la app; si lo omitís se usará el cuerpo de la release.
|
||||||
|
|
||||||
|
Si por algún motivo olvidas subir el manifiesto, la app igualmente tomará el primer asset `.apk` de la release, pero no podrá forzar versiones mínimas.
|
||||||
|
|
||||||
|
### Flujo dentro de la app
|
||||||
|
|
||||||
|
- Cada vez que se abre `MainActivity` se consulta `https://gitea.cbcren.online/api/v1/repos/renato97/app/releases/latest`.
|
||||||
|
- Si `versionCode` del servidor es mayor al instalado se muestra un diálogo para actualizar; el usuario puede abrir la release o descargarla directamente.
|
||||||
|
- Si `minSupportedVersionCode` es mayor al instalado la app bloqueará el uso hasta actualizar, cumpliendo con el requerimiento de controlar instalaciones.
|
||||||
|
- La descarga se gestiona con `DownloadManager` y, una vez completada, se lanza el instalador usando FileProvider.
|
||||||
|
|
||||||
## 📱 Estructura del Proyecto
|
## 📱 Estructura del Proyecto
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -146,8 +184,8 @@ String[] GOOGLE_DNS = {"8.8.8.8", "8.8.4.4"};
|
|||||||
| `applicationId` | `com.streamplayer` |
|
| `applicationId` | `com.streamplayer` |
|
||||||
| `minSdk` | 21 |
|
| `minSdk` | 21 |
|
||||||
| `targetSdk` | 33 |
|
| `targetSdk` | 33 |
|
||||||
| `versionCode` | 1 |
|
| `versionCode` | 90000 |
|
||||||
| `versionName` | "1.0" |
|
| `versionName` | "9.0.0" |
|
||||||
| `compileSdk` | 33 |
|
| `compileSdk` | 33 |
|
||||||
|
|
||||||
## 🔐 Permisos y Seguridad
|
## 🔐 Permisos y Seguridad
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ android {
|
|||||||
applicationId "com.streamplayer"
|
applicationId "com.streamplayer"
|
||||||
minSdk 21
|
minSdk 21
|
||||||
targetSdk 33
|
targetSdk 33
|
||||||
versionCode 1
|
versionCode 90000
|
||||||
versionName "1.0"
|
versionName "9.0.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,6 +30,10 @@ android {
|
|||||||
abortOnError = false
|
abortOnError = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
excludes += 'META-INF/com.android.tools/proguard/coroutines.pro'
|
excludes += 'META-INF/com.android.tools/proguard/coroutines.pro'
|
||||||
@@ -44,6 +49,9 @@ dependencies {
|
|||||||
|
|
||||||
// ExoPlayer para reproducción de video
|
// ExoPlayer para reproducción de video
|
||||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.7'
|
implementation 'com.google.android.exoplayer:exoplayer:2.18.7'
|
||||||
|
implementation 'com.google.android.exoplayer:extension-okhttp:2.18.7'
|
||||||
|
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||||
|
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||||
|
|||||||
@@ -5,9 +5,17 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.software.leanback"
|
||||||
|
android:required="false" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.touchscreen"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:banner="@drawable/banner_streamplayer"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
@@ -15,6 +23,16 @@
|
|||||||
android:theme="@style/Theme.StreamPlayer"
|
android:theme="@style/Theme.StreamPlayer"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".PlayerActivity"
|
android:name=".PlayerActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
@@ -28,6 +46,11 @@
|
|||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.widget.TextView;
|
|||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class ChannelAdapter extends RecyclerView.Adapter<ChannelAdapter.ChannelViewHolder> {
|
public class ChannelAdapter extends RecyclerView.Adapter<ChannelAdapter.ChannelViewHolder> {
|
||||||
@@ -17,11 +18,10 @@ public class ChannelAdapter extends RecyclerView.Adapter<ChannelAdapter.ChannelV
|
|||||||
void onChannelClick(StreamChannel channel);
|
void onChannelClick(StreamChannel channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
private final List<StreamChannel> channels;
|
private final List<StreamChannel> channels = new ArrayList<>();
|
||||||
private final OnChannelClickListener listener;
|
private final OnChannelClickListener listener;
|
||||||
|
|
||||||
public ChannelAdapter(List<StreamChannel> channels, OnChannelClickListener listener) {
|
public ChannelAdapter(OnChannelClickListener listener) {
|
||||||
this.channels = channels;
|
|
||||||
this.listener = listener;
|
this.listener = listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +43,11 @@ public class ChannelAdapter extends RecyclerView.Adapter<ChannelAdapter.ChannelV
|
|||||||
listener.onChannelClick(channel);
|
listener.onChannelClick(channel);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
holder.itemView.setOnFocusChangeListener((v, hasFocus) -> {
|
||||||
|
float scale = hasFocus ? 1.08f : 1f;
|
||||||
|
v.animate().scaleX(scale).scaleY(scale).setDuration(120).start();
|
||||||
|
v.setSelected(hasFocus);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -60,4 +65,12 @@ public class ChannelAdapter extends RecyclerView.Adapter<ChannelAdapter.ChannelV
|
|||||||
name = itemView.findViewById(R.id.channel_name);
|
name = itemView.findViewById(R.id.channel_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void submitList(List<StreamChannel> newChannels) {
|
||||||
|
channels.clear();
|
||||||
|
if (newChannels != null) {
|
||||||
|
channels.addAll(newChannels);
|
||||||
|
}
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package com.streamplayer;
|
package com.streamplayer;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public final class ChannelRepository {
|
public final class ChannelRepository {
|
||||||
|
|
||||||
private static final List<StreamChannel> CHANNELS = Collections.unmodifiableList(Arrays.asList(
|
private static final List<StreamChannel> CHANNELS = createChannels();
|
||||||
|
|
||||||
|
private static List<StreamChannel> createChannels() {
|
||||||
|
List<StreamChannel> channels = new ArrayList<>(Arrays.asList(
|
||||||
new StreamChannel("ESPN", "https://streamtpmedia.com/global2.php?stream=espn"),
|
new StreamChannel("ESPN", "https://streamtpmedia.com/global2.php?stream=espn"),
|
||||||
new StreamChannel("ESPN 2", "https://streamtpmedia.com/global2.php?stream=espn2"),
|
new StreamChannel("ESPN 2", "https://streamtpmedia.com/global2.php?stream=espn2"),
|
||||||
new StreamChannel("ESPN 3", "https://streamtpmedia.com/global2.php?stream=espn3"),
|
new StreamChannel("ESPN 3", "https://streamtpmedia.com/global2.php?stream=espn3"),
|
||||||
@@ -74,6 +79,9 @@ public final class ChannelRepository {
|
|||||||
new StreamChannel("FUTV", "https://streamtpmedia.com/global2.php?stream=futv"),
|
new StreamChannel("FUTV", "https://streamtpmedia.com/global2.php?stream=futv"),
|
||||||
new StreamChannel("LaLiga Hypermotion", "https://streamtpmedia.com/global2.php?stream=laligahypermotion")
|
new StreamChannel("LaLiga Hypermotion", "https://streamtpmedia.com/global2.php?stream=laligahypermotion")
|
||||||
));
|
));
|
||||||
|
channels.sort(Comparator.comparing(StreamChannel::getName, String.CASE_INSENSITIVE_ORDER));
|
||||||
|
return Collections.unmodifiableList(channels);
|
||||||
|
}
|
||||||
|
|
||||||
private ChannelRepository() {
|
private ChannelRepository() {
|
||||||
}
|
}
|
||||||
|
|||||||
97
app/src/main/java/com/streamplayer/EventAdapter.java
Normal file
97
app/src/main/java/com/streamplayer/EventAdapter.java
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package com.streamplayer;
|
||||||
|
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
public class EventAdapter extends RecyclerView.Adapter<EventAdapter.EventViewHolder> {
|
||||||
|
|
||||||
|
public interface OnEventClickListener {
|
||||||
|
void onEventClick(EventItem event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final List<EventItem> events = new ArrayList<>();
|
||||||
|
private final OnEventClickListener listener;
|
||||||
|
|
||||||
|
public EventAdapter(OnEventClickListener listener) {
|
||||||
|
this.listener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void submitList(List<EventItem> newEvents) {
|
||||||
|
events.clear();
|
||||||
|
events.addAll(newEvents);
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public EventViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
View view = LayoutInflater.from(parent.getContext())
|
||||||
|
.inflate(R.layout.item_event, parent, false);
|
||||||
|
return new EventViewHolder(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull EventViewHolder holder, int position) {
|
||||||
|
EventItem event = events.get(position);
|
||||||
|
holder.title.setText(event.getTitle());
|
||||||
|
holder.time.setText(event.getTime());
|
||||||
|
holder.channel.setText(event.getChannelName());
|
||||||
|
holder.status.setText(buildStatusText(event));
|
||||||
|
holder.itemView.setOnClickListener(v -> {
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onEventClick(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return events.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
static class EventViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
final TextView title;
|
||||||
|
final TextView time;
|
||||||
|
final TextView channel;
|
||||||
|
final TextView status;
|
||||||
|
|
||||||
|
EventViewHolder(@NonNull View itemView) {
|
||||||
|
super(itemView);
|
||||||
|
title = itemView.findViewById(R.id.event_title);
|
||||||
|
time = itemView.findViewById(R.id.event_time);
|
||||||
|
channel = itemView.findViewById(R.id.event_channel);
|
||||||
|
status = itemView.findViewById(R.id.event_status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildStatusText(EventItem event) {
|
||||||
|
long start = event.getStartMillis();
|
||||||
|
if (start <= 0) {
|
||||||
|
return event.getStatus();
|
||||||
|
}
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long diff = start - now;
|
||||||
|
if (diff > 0) {
|
||||||
|
long hours = diff / 3600000;
|
||||||
|
long minutes = (diff % 3600000) / 60000;
|
||||||
|
if (hours > 0) {
|
||||||
|
return String.format(Locale.getDefault(), "En %dh %02dm", hours, minutes);
|
||||||
|
} else {
|
||||||
|
return String.format(Locale.getDefault(), "En %d min", Math.max(1, minutes));
|
||||||
|
}
|
||||||
|
} else if (Math.abs(diff) <= 2 * 3600000L) {
|
||||||
|
return "En vivo";
|
||||||
|
} else {
|
||||||
|
return "Finalizado";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/src/main/java/com/streamplayer/EventItem.java
Normal file
49
app/src/main/java/com/streamplayer/EventItem.java
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package com.streamplayer;
|
||||||
|
|
||||||
|
public class EventItem {
|
||||||
|
private final String title;
|
||||||
|
private final String time;
|
||||||
|
private final String category;
|
||||||
|
private final String status;
|
||||||
|
private final String pageUrl;
|
||||||
|
private final String channelName;
|
||||||
|
private final long startMillis;
|
||||||
|
|
||||||
|
public EventItem(String title, String time, String category, String status, String pageUrl, String channelName, long startMillis) {
|
||||||
|
this.title = title;
|
||||||
|
this.time = time;
|
||||||
|
this.category = category;
|
||||||
|
this.status = status;
|
||||||
|
this.pageUrl = pageUrl;
|
||||||
|
this.channelName = channelName;
|
||||||
|
this.startMillis = startMillis;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTime() {
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCategory() {
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPageUrl() {
|
||||||
|
return pageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getChannelName() {
|
||||||
|
return channelName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getStartMillis() {
|
||||||
|
return startMillis;
|
||||||
|
}
|
||||||
|
}
|
||||||
148
app/src/main/java/com/streamplayer/EventRepository.java
Normal file
148
app/src/main/java/com/streamplayer/EventRepository.java
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package com.streamplayer;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class EventRepository {
|
||||||
|
|
||||||
|
private static final String PREFS_NAME = "events_cache";
|
||||||
|
private static final String KEY_JSON = "json";
|
||||||
|
private static final String KEY_TIMESTAMP = "timestamp";
|
||||||
|
private static final long CACHE_DURATION = 24L * 60 * 60 * 1000; // 24 horas
|
||||||
|
private static final String EVENTS_URL = "https://streamtpmedia.com/eventos.json";
|
||||||
|
|
||||||
|
public interface Callback {
|
||||||
|
void onSuccess(List<EventItem> events);
|
||||||
|
|
||||||
|
void onError(String message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void loadEvents(Context context, boolean forceRefresh, Callback callback) {
|
||||||
|
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||||
|
long last = prefs.getLong(KEY_TIMESTAMP, 0);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (!forceRefresh && now - last < CACHE_DURATION) {
|
||||||
|
String cachedJson = prefs.getString(KEY_JSON, null);
|
||||||
|
if (cachedJson != null) {
|
||||||
|
try {
|
||||||
|
callback.onSuccess(parseEvents(cachedJson));
|
||||||
|
return;
|
||||||
|
} catch (JSONException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
String json = downloadJson();
|
||||||
|
List<EventItem> events = parseEvents(json);
|
||||||
|
prefs.edit().putString(KEY_JSON, json).putLong(KEY_TIMESTAMP, System.currentTimeMillis()).apply();
|
||||||
|
callback.onSuccess(events);
|
||||||
|
} catch (IOException | JSONException e) {
|
||||||
|
String cachedJson = prefs.getString(KEY_JSON, null);
|
||||||
|
if (cachedJson != null) {
|
||||||
|
try {
|
||||||
|
callback.onSuccess(parseEvents(cachedJson));
|
||||||
|
return;
|
||||||
|
} catch (JSONException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback.onError(e.getMessage() != null ? e.getMessage() : "Error desconocido");
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String downloadJson() throws IOException {
|
||||||
|
URL url = new URL(EVENTS_URL);
|
||||||
|
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||||
|
connection.setConnectTimeout(15000);
|
||||||
|
connection.setReadTimeout(15000);
|
||||||
|
connection.setRequestMethod("GET");
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
|
||||||
|
StringBuilder builder = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
builder.append(line);
|
||||||
|
}
|
||||||
|
return builder.toString();
|
||||||
|
} finally {
|
||||||
|
connection.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<EventItem> parseEvents(String json) throws JSONException {
|
||||||
|
JSONArray array = new JSONArray(json);
|
||||||
|
List<EventItem> events = new ArrayList<>();
|
||||||
|
for (int i = 0; i < array.length(); i++) {
|
||||||
|
JSONObject obj = array.getJSONObject(i);
|
||||||
|
String title = obj.optString("title");
|
||||||
|
String time = obj.optString("time");
|
||||||
|
String category = obj.optString("category");
|
||||||
|
String status = obj.optString("status");
|
||||||
|
String link = obj.optString("link");
|
||||||
|
String normalized = normalizeLink(link);
|
||||||
|
long startMillis = parseEventTime(time);
|
||||||
|
events.add(new EventItem(title, time, category, status, normalized, extractChannelName(link), startMillis));
|
||||||
|
}
|
||||||
|
return Collections.unmodifiableList(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeLink(String link) {
|
||||||
|
if (link == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return link.replace("global1.php", "global2.php");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractChannelName(String link) {
|
||||||
|
if (link == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int idx = link.indexOf("stream=");
|
||||||
|
if (idx == -1) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return link.substring(idx + 7).replace("_", " ").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private long parseEventTime(String time) {
|
||||||
|
if (time == null || time.isEmpty()) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm");
|
||||||
|
LocalTime localTime = LocalTime.parse(time.trim(), formatter);
|
||||||
|
ZoneId zone = ZoneId.of("America/Argentina/Buenos_Aires");
|
||||||
|
LocalDate today = LocalDate.now(zone);
|
||||||
|
ZonedDateTime start = ZonedDateTime.of(LocalDateTime.of(today, localTime), zone);
|
||||||
|
ZonedDateTime now = ZonedDateTime.now(zone);
|
||||||
|
if (start.isBefore(now.minusHours(12))) {
|
||||||
|
start = start.plusDays(1);
|
||||||
|
}
|
||||||
|
return start.toInstant().toEpochMilli();
|
||||||
|
} catch (DateTimeParseException e) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,368 @@
|
|||||||
package com.streamplayer;
|
package com.streamplayer;
|
||||||
|
|
||||||
|
import android.app.AlertDialog;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.ProgressBar;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.recyclerview.widget.GridLayoutManager;
|
import androidx.recyclerview.widget.GridLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
public class MainActivity extends AppCompatActivity {
|
public class MainActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private RecyclerView sectionList;
|
||||||
|
private RecyclerView contentList;
|
||||||
|
private ProgressBar loadingIndicator;
|
||||||
|
private TextView messageView;
|
||||||
|
private TextView contentTitle;
|
||||||
|
|
||||||
|
private ChannelAdapter channelAdapter;
|
||||||
|
private EventAdapter eventAdapter;
|
||||||
|
private EventRepository eventRepository;
|
||||||
|
private SectionAdapter sectionAdapter;
|
||||||
|
private GridLayoutManager channelLayoutManager;
|
||||||
|
private LinearLayoutManager eventLayoutManager;
|
||||||
|
private final List<EventItem> cachedEvents = new ArrayList<>();
|
||||||
|
private List<SectionEntry> sections;
|
||||||
|
private SectionEntry currentSection;
|
||||||
|
private UpdateManager updateManager;
|
||||||
|
private AlertDialog updateDialog;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
setContentView(R.layout.activity_main);
|
setContentView(R.layout.activity_main);
|
||||||
|
|
||||||
RecyclerView recyclerView = findViewById(R.id.channel_grid);
|
sectionList = findViewById(R.id.section_list);
|
||||||
recyclerView.setLayoutManager(new GridLayoutManager(this, 3));
|
contentList = findViewById(R.id.content_list);
|
||||||
ChannelAdapter adapter = new ChannelAdapter(
|
loadingIndicator = findViewById(R.id.loading_indicator);
|
||||||
ChannelRepository.getChannels(),
|
messageView = findViewById(R.id.message_view);
|
||||||
channel -> {
|
contentTitle = findViewById(R.id.content_title);
|
||||||
Intent intent = new Intent(MainActivity.this, PlayerActivity.class);
|
|
||||||
intent.putExtra(PlayerActivity.EXTRA_CHANNEL_NAME, channel.getName());
|
channelAdapter = new ChannelAdapter(
|
||||||
intent.putExtra(PlayerActivity.EXTRA_CHANNEL_URL, channel.getPageUrl());
|
channel -> openPlayer(channel.getName(), channel.getPageUrl()));
|
||||||
startActivity(intent);
|
eventAdapter = new EventAdapter(event -> openPlayer(event.getTitle(), event.getPageUrl()));
|
||||||
|
eventRepository = new EventRepository();
|
||||||
|
channelLayoutManager = new GridLayoutManager(this, getSpanCount());
|
||||||
|
eventLayoutManager = new LinearLayoutManager(this);
|
||||||
|
|
||||||
|
sections = buildSections();
|
||||||
|
sectionList.setLayoutManager(new LinearLayoutManager(this));
|
||||||
|
sectionAdapter = new SectionAdapter(getSectionTitles(), this::selectSection);
|
||||||
|
sectionList.setAdapter(sectionAdapter);
|
||||||
|
|
||||||
|
selectSection(0);
|
||||||
|
|
||||||
|
updateManager = new UpdateManager(this);
|
||||||
|
updateManager.checkForUpdates(new UpdateManager.UpdateCallback() {
|
||||||
|
@Override
|
||||||
|
public void onUpdateAvailable(UpdateManager.UpdateInfo info) {
|
||||||
|
handleUpdateInfo(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onUpToDate() {
|
||||||
|
// Nothing to do.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String message) {
|
||||||
|
Toast.makeText(MainActivity.this,
|
||||||
|
getString(R.string.update_error_checking, message),
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
recyclerView.setAdapter(adapter);
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
if (updateManager != null) {
|
||||||
|
updateManager.resumePendingInstall(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDestroy() {
|
||||||
|
super.onDestroy();
|
||||||
|
if (updateDialog != null && updateDialog.isShowing()) {
|
||||||
|
updateDialog.dismiss();
|
||||||
|
}
|
||||||
|
if (updateManager != null) {
|
||||||
|
updateManager.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void selectSection(int index) {
|
||||||
|
if (sections == null || sections.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (index < 0 || index >= sections.size()) {
|
||||||
|
index = 0;
|
||||||
|
}
|
||||||
|
sectionAdapter.setSelectedIndex(index);
|
||||||
|
currentSection = sections.get(index);
|
||||||
|
if (currentSection.type == SectionEntry.Type.EVENTS) {
|
||||||
|
showEvents();
|
||||||
|
} else {
|
||||||
|
showChannels(currentSection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showChannels(SectionEntry section) {
|
||||||
|
contentTitle.setText(section.title);
|
||||||
|
contentList.setLayoutManager(channelLayoutManager);
|
||||||
|
contentList.setAdapter(channelAdapter);
|
||||||
|
loadingIndicator.setVisibility(View.GONE);
|
||||||
|
channelAdapter.submitList(section.channels);
|
||||||
|
if (section.channels.isEmpty()) {
|
||||||
|
messageView.setVisibility(View.VISIBLE);
|
||||||
|
messageView.setText(R.string.message_no_channels);
|
||||||
|
} else {
|
||||||
|
messageView.setVisibility(View.GONE);
|
||||||
|
contentList.post(() -> contentList.scrollToPosition(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showEvents() {
|
||||||
|
contentTitle.setText(currentSection != null ? currentSection.title : getString(R.string.section_events));
|
||||||
|
contentList.setLayoutManager(eventLayoutManager);
|
||||||
|
contentList.setAdapter(eventAdapter);
|
||||||
|
if (cachedEvents.isEmpty()) {
|
||||||
|
loadEvents(false);
|
||||||
|
} else {
|
||||||
|
displayEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadEvents(boolean forceRefresh) {
|
||||||
|
loadingIndicator.setVisibility(View.VISIBLE);
|
||||||
|
messageView.setVisibility(View.GONE);
|
||||||
|
eventRepository.loadEvents(this, forceRefresh, new EventRepository.Callback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(List<EventItem> events) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
cachedEvents.clear();
|
||||||
|
cachedEvents.addAll(events);
|
||||||
|
if (currentSection != null && currentSection.type == SectionEntry.Type.EVENTS) {
|
||||||
|
displayEvents();
|
||||||
|
} else {
|
||||||
|
loadingIndicator.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String message) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
loadingIndicator.setVisibility(View.GONE);
|
||||||
|
messageView.setVisibility(View.VISIBLE);
|
||||||
|
messageView.setText(getString(R.string.message_events_error, message));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void displayEvents() {
|
||||||
|
loadingIndicator.setVisibility(View.GONE);
|
||||||
|
if (cachedEvents.isEmpty()) {
|
||||||
|
messageView.setVisibility(View.VISIBLE);
|
||||||
|
messageView.setText(R.string.message_no_events);
|
||||||
|
eventAdapter.submitList(new ArrayList<>());
|
||||||
|
} else {
|
||||||
|
messageView.setVisibility(View.GONE);
|
||||||
|
eventAdapter.submitList(new ArrayList<>(cachedEvents));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openPlayer(String name, String pageUrl) {
|
||||||
|
Intent intent = new Intent(MainActivity.this, PlayerActivity.class);
|
||||||
|
intent.putExtra(PlayerActivity.EXTRA_CHANNEL_NAME, name);
|
||||||
|
intent.putExtra(PlayerActivity.EXTRA_CHANNEL_URL, pageUrl);
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleUpdateInfo(UpdateManager.UpdateInfo info) {
|
||||||
|
if (info == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean forceUpdate = info.isMandatory(BuildConfig.VERSION_CODE);
|
||||||
|
showUpdateDialog(info, forceUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showUpdateDialog(UpdateManager.UpdateInfo info, boolean mandatory) {
|
||||||
|
if (isFinishing()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (updateDialog != null && updateDialog.isShowing()) {
|
||||||
|
updateDialog.dismiss();
|
||||||
|
}
|
||||||
|
AlertDialog.Builder builder = new AlertDialog.Builder(this)
|
||||||
|
.setTitle(mandatory ? R.string.update_required_title : R.string.update_available_title)
|
||||||
|
.setMessage(buildUpdateMessage(info))
|
||||||
|
.setPositiveButton(R.string.update_action_download,
|
||||||
|
(dialog, which) -> updateManager.downloadUpdate(MainActivity.this, info))
|
||||||
|
.setNeutralButton(R.string.update_action_view_release,
|
||||||
|
(dialog, which) -> openReleasePage(info));
|
||||||
|
if (mandatory) {
|
||||||
|
builder.setCancelable(false);
|
||||||
|
builder.setNegativeButton(R.string.update_action_close_app,
|
||||||
|
(dialog, which) -> finish());
|
||||||
|
} else {
|
||||||
|
builder.setNegativeButton(R.string.update_action_later, null);
|
||||||
|
}
|
||||||
|
updateDialog = builder.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private CharSequence buildUpdateMessage(UpdateManager.UpdateInfo info) {
|
||||||
|
StringBuilder builder = new StringBuilder();
|
||||||
|
builder.append(getString(R.string.update_current_version,
|
||||||
|
BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE));
|
||||||
|
builder.append('\n');
|
||||||
|
builder.append(getString(R.string.update_latest_version,
|
||||||
|
info.versionName, info.versionCode));
|
||||||
|
if (info.minSupportedVersionCode > 0) {
|
||||||
|
builder.append('\n').append(getString(R.string.update_min_supported,
|
||||||
|
info.minSupportedVersionCode));
|
||||||
|
}
|
||||||
|
String size = info.formatSize(this);
|
||||||
|
if (!size.isEmpty()) {
|
||||||
|
builder.append('\n').append(getString(R.string.update_download_size, size));
|
||||||
|
}
|
||||||
|
if (info.downloadCount > 0) {
|
||||||
|
builder.append('\n').append(getString(R.string.update_downloads,
|
||||||
|
info.downloadCount));
|
||||||
|
}
|
||||||
|
if (!info.releaseNotes.isEmpty()) {
|
||||||
|
builder.append("\n\n");
|
||||||
|
builder.append(getString(R.string.update_release_notes_title));
|
||||||
|
builder.append('\n');
|
||||||
|
builder.append(info.getReleaseNotesPreview());
|
||||||
|
}
|
||||||
|
if (!info.isMandatory(BuildConfig.VERSION_CODE)) {
|
||||||
|
builder.append("\n\n");
|
||||||
|
builder.append(getString(R.string.update_optional_hint));
|
||||||
|
}
|
||||||
|
return builder.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openReleasePage(UpdateManager.UpdateInfo info) {
|
||||||
|
String url = info.releasePageUrl;
|
||||||
|
if (url == null || url.isEmpty()) {
|
||||||
|
url = info.downloadUrl;
|
||||||
|
}
|
||||||
|
if (url == null || url.isEmpty()) {
|
||||||
|
Toast.makeText(this, R.string.update_error_missing_url, Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
|
||||||
|
try {
|
||||||
|
startActivity(intent);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Toast.makeText(this, R.string.update_error_open_release, Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int getSpanCount() {
|
||||||
|
return getResources().getInteger(R.integer.channel_grid_span);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SectionEntry> buildSections() {
|
||||||
|
List<SectionEntry> list = new ArrayList<>();
|
||||||
|
list.add(SectionEntry.events(getString(R.string.section_events)));
|
||||||
|
|
||||||
|
Map<String, List<StreamChannel>> grouped = new HashMap<>();
|
||||||
|
List<StreamChannel> allChannels = ChannelRepository.getChannels();
|
||||||
|
for (StreamChannel channel : allChannels) {
|
||||||
|
String key = deriveGroupName(channel.getName());
|
||||||
|
grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<StreamChannel> espnChannels = grouped.remove("ESPN");
|
||||||
|
if (espnChannels != null && !espnChannels.isEmpty()) {
|
||||||
|
list.add(SectionEntry.channels("ESPN", espnChannels));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> remaining = new ArrayList<>(grouped.keySet());
|
||||||
|
Collections.sort(remaining, String.CASE_INSENSITIVE_ORDER);
|
||||||
|
for (String key : remaining) {
|
||||||
|
List<StreamChannel> channels = grouped.get(key);
|
||||||
|
if (channels == null || channels.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
list.add(SectionEntry.channels(key, channels));
|
||||||
|
}
|
||||||
|
|
||||||
|
list.add(SectionEntry.channels(getString(R.string.section_all_channels), allChannels));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> getSectionTitles() {
|
||||||
|
List<String> titles = new ArrayList<>();
|
||||||
|
for (SectionEntry entry : sections) {
|
||||||
|
titles.add(entry.title);
|
||||||
|
}
|
||||||
|
return titles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String deriveGroupName(String name) {
|
||||||
|
if (name == null) {
|
||||||
|
return getString(R.string.section_all_channels);
|
||||||
|
}
|
||||||
|
String upper = name.toUpperCase(Locale.US);
|
||||||
|
if (upper.startsWith("ESPN")) {
|
||||||
|
return "ESPN";
|
||||||
|
} else if (upper.contains("FOX SPORTS")) {
|
||||||
|
return "Fox Sports";
|
||||||
|
} else if (upper.contains("FOX")) {
|
||||||
|
return "Fox";
|
||||||
|
} else if (upper.contains("TNT")) {
|
||||||
|
return "TNT";
|
||||||
|
} else if (upper.contains("DAZN")) {
|
||||||
|
return "DAZN";
|
||||||
|
} else if (upper.contains("TUDN")) {
|
||||||
|
return "TUDN";
|
||||||
|
} else if (upper.contains("TYC")) {
|
||||||
|
return "TyC";
|
||||||
|
} else if (upper.contains("GOL")) {
|
||||||
|
return "Gol";
|
||||||
|
}
|
||||||
|
int spaceIndex = upper.indexOf(' ');
|
||||||
|
return spaceIndex > 0 ? upper.substring(0, spaceIndex) : upper;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class SectionEntry {
|
||||||
|
enum Type { EVENTS, CHANNELS }
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final Type type;
|
||||||
|
final List<StreamChannel> channels;
|
||||||
|
|
||||||
|
private SectionEntry(String title, Type type, List<StreamChannel> channels) {
|
||||||
|
this.title = title;
|
||||||
|
this.type = type;
|
||||||
|
this.channels = channels == null ? new ArrayList<>() : new ArrayList<>(channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
static SectionEntry events(String title) {
|
||||||
|
return new SectionEntry(title, Type.EVENTS, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static SectionEntry channels(String title, List<StreamChannel> channels) {
|
||||||
|
return new SectionEntry(title, Type.CHANNELS, channels);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,22 @@ import com.google.android.exoplayer2.ExoPlayer;
|
|||||||
import com.google.android.exoplayer2.MediaItem;
|
import com.google.android.exoplayer2.MediaItem;
|
||||||
import com.google.android.exoplayer2.PlaybackException;
|
import com.google.android.exoplayer2.PlaybackException;
|
||||||
import com.google.android.exoplayer2.Player;
|
import com.google.android.exoplayer2.Player;
|
||||||
|
import com.google.android.exoplayer2.DefaultRenderersFactory;
|
||||||
|
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource;
|
||||||
|
import com.google.android.exoplayer2.source.MediaSource;
|
||||||
|
import com.google.android.exoplayer2.source.hls.HlsMediaSource;
|
||||||
import com.google.android.exoplayer2.ui.PlayerView;
|
import com.google.android.exoplayer2.ui.PlayerView;
|
||||||
|
import com.google.android.exoplayer2.util.Util;
|
||||||
|
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl;
|
||||||
|
import okhttp3.OkHttpClient;
|
||||||
|
import okhttp3.dnsoverhttps.DnsOverHttps;
|
||||||
|
|
||||||
public class PlayerActivity extends AppCompatActivity {
|
public class PlayerActivity extends AppCompatActivity {
|
||||||
|
|
||||||
@@ -32,6 +47,7 @@ public class PlayerActivity extends AppCompatActivity {
|
|||||||
private String channelName;
|
private String channelName;
|
||||||
private String channelUrl;
|
private String channelUrl;
|
||||||
private boolean overlayVisible = true;
|
private boolean overlayVisible = true;
|
||||||
|
private OkHttpClient okHttpClient;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
@@ -91,7 +107,13 @@ public class PlayerActivity extends AppCompatActivity {
|
|||||||
private void startPlayback(String streamUrl) {
|
private void startPlayback(String streamUrl) {
|
||||||
try {
|
try {
|
||||||
releasePlayer();
|
releasePlayer();
|
||||||
player = new ExoPlayer.Builder(this).build();
|
DefaultRenderersFactory renderersFactory = new DefaultRenderersFactory(this)
|
||||||
|
.setEnableDecoderFallback(true)
|
||||||
|
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON);
|
||||||
|
player = new ExoPlayer.Builder(this, renderersFactory)
|
||||||
|
.setSeekForwardIncrementMs(10_000)
|
||||||
|
.setSeekBackIncrementMs(10_000)
|
||||||
|
.build();
|
||||||
playerView.setPlayer(player);
|
playerView.setPlayer(player);
|
||||||
|
|
||||||
player.addListener(new Player.Listener() {
|
player.addListener(new Player.Listener() {
|
||||||
@@ -106,12 +128,14 @@ public class PlayerActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onPlayerError(PlaybackException error) {
|
public void onPlayerError(PlaybackException error) {
|
||||||
showError("Error al reproducir: " + error.getMessage());
|
String detail = error.getCause() != null ?
|
||||||
|
error.getCause().getMessage() : "";
|
||||||
|
showError("Error al reproducir: " + error.getMessage() + " " + detail);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
MediaItem mediaItem = MediaItem.fromUri(streamUrl);
|
MediaItem mediaItem = MediaItem.fromUri(streamUrl);
|
||||||
player.setMediaItem(mediaItem);
|
player.setMediaSource(buildMediaSource(mediaItem));
|
||||||
player.prepare();
|
player.prepare();
|
||||||
player.setPlayWhenReady(true);
|
player.setPlayWhenReady(true);
|
||||||
setOverlayVisible(false);
|
setOverlayVisible(false);
|
||||||
@@ -145,11 +169,62 @@ public class PlayerActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private MediaSource buildMediaSource(MediaItem mediaItem) {
|
||||||
|
Map<String, String> headers = new HashMap<>();
|
||||||
|
headers.put("Referer", channelUrl);
|
||||||
|
headers.put("Origin", "https://streamtpmedia.com");
|
||||||
|
headers.put("Accept", "*/*");
|
||||||
|
headers.put("Connection", "keep-alive");
|
||||||
|
|
||||||
|
String userAgent = Util.getUserAgent(this, "StreamPlayer");
|
||||||
|
|
||||||
|
OkHttpDataSource.Factory factory = new OkHttpDataSource.Factory(provideOkHttpClient())
|
||||||
|
.setUserAgent(userAgent)
|
||||||
|
.setDefaultRequestProperties(headers);
|
||||||
|
return new HlsMediaSource.Factory(factory).createMediaSource(mediaItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OkHttpClient provideOkHttpClient() {
|
||||||
|
if (okHttpClient != null) {
|
||||||
|
return okHttpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
OkHttpClient bootstrap = new OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.retryOnConnectionFailure(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
DnsOverHttps dohDns = new DnsOverHttps.Builder()
|
||||||
|
.client(bootstrap)
|
||||||
|
.url(HttpUrl.get("https://dns.adguard-dns.com/dns-query"))
|
||||||
|
.bootstrapDnsHosts(
|
||||||
|
InetAddress.getByName("94.140.14.14"),
|
||||||
|
InetAddress.getByName("94.140.15.15"))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
okHttpClient = bootstrap.newBuilder()
|
||||||
|
.dns(dohDns)
|
||||||
|
.build();
|
||||||
|
} catch (UnknownHostException e) {
|
||||||
|
okHttpClient = new OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.retryOnConnectionFailure(true)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
return okHttpClient;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onStart() {
|
protected void onStart() {
|
||||||
super.onStart();
|
super.onStart();
|
||||||
if (player != null) {
|
if (player != null) {
|
||||||
playerView.onResume();
|
playerView.onResume();
|
||||||
|
} else if (channelUrl != null) {
|
||||||
|
loadChannel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,9 +247,7 @@ public class PlayerActivity extends AppCompatActivity {
|
|||||||
@Override
|
@Override
|
||||||
protected void onStop() {
|
protected void onStop() {
|
||||||
super.onStop();
|
super.onStop();
|
||||||
if (player != null) {
|
releasePlayer();
|
||||||
playerView.onPause();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
88
app/src/main/java/com/streamplayer/SectionAdapter.java
Normal file
88
app/src/main/java/com/streamplayer/SectionAdapter.java
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package com.streamplayer;
|
||||||
|
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class SectionAdapter extends RecyclerView.Adapter<SectionAdapter.SectionViewHolder> {
|
||||||
|
|
||||||
|
public interface OnSectionSelectedListener {
|
||||||
|
void onSectionSelected(int position);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final List<String> sections;
|
||||||
|
private final OnSectionSelectedListener listener;
|
||||||
|
private int selectedIndex = 0;
|
||||||
|
|
||||||
|
public SectionAdapter(List<String> sections, OnSectionSelectedListener listener) {
|
||||||
|
this.sections = sections;
|
||||||
|
this.listener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public SectionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
View view = LayoutInflater.from(parent.getContext())
|
||||||
|
.inflate(R.layout.item_section, parent, false);
|
||||||
|
return new SectionViewHolder(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull SectionViewHolder holder, int position) {
|
||||||
|
holder.title.setText(sections.get(position));
|
||||||
|
holder.itemView.setSelected(position == selectedIndex);
|
||||||
|
holder.itemView.setOnClickListener(v -> notifySelection(holder));
|
||||||
|
holder.itemView.setOnFocusChangeListener((v, hasFocus) -> {
|
||||||
|
if (hasFocus) {
|
||||||
|
notifySelection(holder);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return sections.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSelectedIndex(int index) {
|
||||||
|
if (index < 0 || index >= sections.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedIndex == index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int previous = selectedIndex;
|
||||||
|
selectedIndex = index;
|
||||||
|
notifyItemChanged(previous);
|
||||||
|
notifyItemChanged(selectedIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getSelectedIndex() {
|
||||||
|
return selectedIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifySelection(SectionViewHolder holder) {
|
||||||
|
int position = holder.getBindingAdapterPosition();
|
||||||
|
if (position == RecyclerView.NO_POSITION) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onSectionSelected(position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static class SectionViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
final TextView title;
|
||||||
|
|
||||||
|
SectionViewHolder(@NonNull View itemView) {
|
||||||
|
super(itemView);
|
||||||
|
title = itemView.findViewById(R.id.section_title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
518
app/src/main/java/com/streamplayer/UpdateManager.java
Normal file
518
app/src/main/java/com/streamplayer/UpdateManager.java
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
package com.streamplayer;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.app.DownloadManager;
|
||||||
|
import android.content.ActivityNotFoundException;
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.IntentFilter;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Environment;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.provider.Settings;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.text.format.Formatter;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.core.content.FileProvider;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.lang.ref.WeakReference;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient;
|
||||||
|
import okhttp3.Request;
|
||||||
|
import okhttp3.Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encapsula toda la lógica para consultar releases de Gitea, descargar el APK y lanzarlo.
|
||||||
|
*/
|
||||||
|
public class UpdateManager {
|
||||||
|
|
||||||
|
private static final String TAG = "UpdateManager";
|
||||||
|
private static final String LATEST_RELEASE_URL =
|
||||||
|
"https://gitea.cbcren.online/api/v1/repos/renato97/app/releases/latest";
|
||||||
|
|
||||||
|
private final Context appContext;
|
||||||
|
private final Handler mainHandler;
|
||||||
|
private final ExecutorService networkExecutor;
|
||||||
|
private final OkHttpClient httpClient;
|
||||||
|
|
||||||
|
private WeakReference<Activity> activityRef;
|
||||||
|
private DownloadReceiver downloadReceiver;
|
||||||
|
private long currentDownloadId = -1L;
|
||||||
|
private File downloadingFile;
|
||||||
|
private File pendingInstallFile;
|
||||||
|
private UpdateInfo cachedUpdate;
|
||||||
|
|
||||||
|
public UpdateManager(Context context) {
|
||||||
|
this.appContext = context.getApplicationContext();
|
||||||
|
this.mainHandler = new Handler(Looper.getMainLooper());
|
||||||
|
this.networkExecutor = Executors.newSingleThreadExecutor();
|
||||||
|
this.httpClient = new OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(20, TimeUnit.SECONDS)
|
||||||
|
.callTimeout(25, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void checkForUpdates(UpdateCallback callback) {
|
||||||
|
networkExecutor.execute(() -> {
|
||||||
|
try {
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(LATEST_RELEASE_URL)
|
||||||
|
.get()
|
||||||
|
.build();
|
||||||
|
try (Response response = httpClient.newCall(request).execute()) {
|
||||||
|
if (response.body() == null) {
|
||||||
|
postError(callback, appContext.getString(R.string.update_error_empty_response));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.isSuccessful()) {
|
||||||
|
postError(callback, appContext.getString(R.string.update_error_http,
|
||||||
|
response.code()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String body = response.body().string();
|
||||||
|
UpdateInfo info = parseRelease(body);
|
||||||
|
cachedUpdate = info;
|
||||||
|
if (info != null && info.isUpdateAvailable(BuildConfig.VERSION_CODE)) {
|
||||||
|
postAvailable(callback, info);
|
||||||
|
} else {
|
||||||
|
postUpToDate(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException | JSONException e) {
|
||||||
|
Log.w(TAG, "Error checking updates", e);
|
||||||
|
postError(callback, e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public UpdateInfo getCachedUpdate() {
|
||||||
|
return cachedUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void downloadUpdate(Activity activity, UpdateInfo info) {
|
||||||
|
if (info == null || TextUtils.isEmpty(info.downloadUrl)) {
|
||||||
|
showToast(appContext.getString(R.string.update_error_missing_url));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DownloadManager downloadManager =
|
||||||
|
(DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE);
|
||||||
|
if (downloadManager == null) {
|
||||||
|
showToast(appContext.getString(R.string.update_error_download_manager));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
File targetDir = appContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
|
||||||
|
if (targetDir == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
targetDir = appContext.getExternalFilesDir(null);
|
||||||
|
}
|
||||||
|
if (targetDir == null) {
|
||||||
|
showToast(appContext.getString(R.string.update_error_storage));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!targetDir.exists() && !targetDir.mkdirs()) {
|
||||||
|
showToast(appContext.getString(R.string.update_error_storage));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String fileName = info.getResolvedFileName();
|
||||||
|
File apkFile = new File(targetDir, fileName);
|
||||||
|
if (apkFile.exists() && !apkFile.delete()) {
|
||||||
|
showToast(appContext.getString(R.string.update_error_storage));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri destination = Uri.fromFile(apkFile);
|
||||||
|
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(info.downloadUrl));
|
||||||
|
request.setTitle(appContext.getString(R.string.update_notification_title, info.versionName));
|
||||||
|
request.setDescription(appContext.getString(R.string.update_notification_description));
|
||||||
|
request.setAllowedOverMetered(true);
|
||||||
|
request.setAllowedOverRoaming(false);
|
||||||
|
request.setNotificationVisibility(
|
||||||
|
DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
|
||||||
|
request.setDestinationUri(destination);
|
||||||
|
|
||||||
|
try {
|
||||||
|
currentDownloadId = downloadManager.enqueue(request);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
showToast(appContext.getString(R.string.update_error_download, e.getMessage()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadingFile = apkFile;
|
||||||
|
activityRef = new WeakReference<>(activity);
|
||||||
|
registerDownloadReceiver();
|
||||||
|
showToast(appContext.getString(R.string.update_download_started));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void resumePendingInstall(Activity activity) {
|
||||||
|
if (pendingInstallFile == null || !pendingInstallFile.exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
installDownloadedApk(activity, pendingInstallFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void release() {
|
||||||
|
unregisterDownloadReceiver();
|
||||||
|
networkExecutor.shutdownNow();
|
||||||
|
activityRef = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UpdateInfo parseRelease(String responseBody) throws JSONException, IOException {
|
||||||
|
JSONObject releaseJson = new JSONObject(responseBody);
|
||||||
|
String tagName = releaseJson.optString("tag_name", "");
|
||||||
|
String versionName = deriveVersionName(tagName, releaseJson.optString("name"));
|
||||||
|
int versionCode = parseVersionCode(versionName);
|
||||||
|
String releaseNotes = releaseJson.optString("body", "");
|
||||||
|
String releasePageUrl = releaseJson.optString("html_url", "");
|
||||||
|
JSONArray assets = releaseJson.optJSONArray("assets");
|
||||||
|
JSONObject apkAsset = findApkAsset(assets);
|
||||||
|
String downloadUrl = apkAsset != null ? apkAsset.optString("browser_download_url", "") : "";
|
||||||
|
String downloadFileName = apkAsset != null ? apkAsset.optString("name", "") : "";
|
||||||
|
long sizeBytes = apkAsset != null ? apkAsset.optLong("size", 0L) : 0L;
|
||||||
|
int downloadCount = apkAsset != null ? apkAsset.optInt("download_count", 0) : 0;
|
||||||
|
|
||||||
|
int minSupported = 0;
|
||||||
|
boolean forceUpdate = false;
|
||||||
|
JSONObject manifestJson = fetchManifest(assets);
|
||||||
|
if (manifestJson != null) {
|
||||||
|
versionCode = manifestJson.optInt("versionCode", versionCode);
|
||||||
|
versionName = manifestJson.optString("versionName", versionName);
|
||||||
|
minSupported = manifestJson.optInt("minSupportedVersionCode", 0);
|
||||||
|
forceUpdate = manifestJson.optBoolean("forceUpdate", false);
|
||||||
|
String manifestUrl = manifestJson.optString("downloadUrl", null);
|
||||||
|
if (!TextUtils.isEmpty(manifestUrl)) {
|
||||||
|
downloadUrl = manifestUrl;
|
||||||
|
}
|
||||||
|
if (manifestJson.has("fileName")) {
|
||||||
|
downloadFileName = manifestJson.optString("fileName", downloadFileName);
|
||||||
|
}
|
||||||
|
if (manifestJson.has("sizeBytes")) {
|
||||||
|
sizeBytes = manifestJson.optLong("sizeBytes", sizeBytes);
|
||||||
|
}
|
||||||
|
if (manifestJson.has("notes") && TextUtils.isEmpty(releaseNotes)) {
|
||||||
|
releaseNotes = manifestJson.optString("notes", releaseNotes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TextUtils.isEmpty(downloadUrl)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UpdateInfo(versionCode, versionName, releaseNotes, downloadUrl,
|
||||||
|
downloadFileName, sizeBytes, downloadCount, releasePageUrl,
|
||||||
|
minSupported, forceUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONObject fetchManifest(JSONArray assets) throws IOException, JSONException {
|
||||||
|
if (assets == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < assets.length(); i++) {
|
||||||
|
JSONObject asset = assets.optJSONObject(i);
|
||||||
|
if (asset == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String name = asset.optString("name", "").toLowerCase(Locale.US);
|
||||||
|
if (TextUtils.isEmpty(name) || !name.endsWith(".json")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!(name.contains("update") || name.contains("manifest"))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String url = asset.optString("browser_download_url", "");
|
||||||
|
if (TextUtils.isEmpty(url)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Request request = new Request.Builder().url(url).get().build();
|
||||||
|
try (Response response = httpClient.newCall(request).execute()) {
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String json = response.body().string();
|
||||||
|
if (!TextUtils.isEmpty(json)) {
|
||||||
|
return new JSONObject(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONObject findApkAsset(JSONArray assets) {
|
||||||
|
if (assets == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
JSONObject fallback = null;
|
||||||
|
for (int i = 0; i < assets.length(); i++) {
|
||||||
|
JSONObject asset = assets.optJSONObject(i);
|
||||||
|
if (asset == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fallback == null) {
|
||||||
|
fallback = asset;
|
||||||
|
}
|
||||||
|
String name = asset.optString("name", "").toLowerCase(Locale.US);
|
||||||
|
if (name.endsWith(".apk")) {
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String deriveVersionName(String tagName, String fallback) {
|
||||||
|
String base = !TextUtils.isEmpty(tagName) ? tagName : fallback;
|
||||||
|
if (TextUtils.isEmpty(base)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return base.replaceFirst("^[Vv]", "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int parseVersionCode(String versionName) {
|
||||||
|
if (TextUtils.isEmpty(versionName)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
String normalized = versionName.replaceAll("[^0-9\\.]", "");
|
||||||
|
if (TextUtils.isEmpty(normalized)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
String[] parts = normalized.split("\\.");
|
||||||
|
int major = parsePart(parts, 0);
|
||||||
|
int minor = parsePart(parts, 1);
|
||||||
|
int patch = parsePart(parts, 2);
|
||||||
|
return major * 10000 + minor * 100 + patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int parsePart(String[] parts, int index) {
|
||||||
|
if (parts.length <= index) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(parts[index]);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void installDownloadedApk(Activity activity, File apkFile) {
|
||||||
|
if (activity == null || apkFile == null || !apkFile.exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
boolean canInstall = appContext.getPackageManager().canRequestPackageInstalls();
|
||||||
|
if (!canInstall) {
|
||||||
|
pendingInstallFile = apkFile;
|
||||||
|
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
|
Uri.parse("package:" + appContext.getPackageName()));
|
||||||
|
try {
|
||||||
|
activity.startActivity(intent);
|
||||||
|
} catch (ActivityNotFoundException ignored) {
|
||||||
|
showToast(appContext.getString(R.string.update_error_install_permissions));
|
||||||
|
}
|
||||||
|
showToast(appContext.getString(R.string.update_permission_request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Uri uri = FileProvider.getUriForFile(appContext,
|
||||||
|
appContext.getPackageName() + ".fileprovider", apkFile);
|
||||||
|
Intent installIntent = new Intent(Intent.ACTION_VIEW);
|
||||||
|
installIntent.setDataAndType(uri, "application/vnd.android.package-archive");
|
||||||
|
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||||
|
installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
try {
|
||||||
|
activity.startActivity(installIntent);
|
||||||
|
pendingInstallFile = null;
|
||||||
|
} catch (ActivityNotFoundException e) {
|
||||||
|
showToast(appContext.getString(R.string.update_error_install_intent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void registerDownloadReceiver() {
|
||||||
|
if (downloadReceiver != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadReceiver = new DownloadReceiver();
|
||||||
|
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
|
||||||
|
appContext.registerReceiver(downloadReceiver, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void unregisterDownloadReceiver() {
|
||||||
|
if (downloadReceiver != null) {
|
||||||
|
try {
|
||||||
|
appContext.unregisterReceiver(downloadReceiver);
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
}
|
||||||
|
downloadReceiver = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleDownloadComplete(long downloadId) {
|
||||||
|
if (downloadId != currentDownloadId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DownloadManager downloadManager =
|
||||||
|
(DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE);
|
||||||
|
if (downloadManager == null) {
|
||||||
|
showToast(appContext.getString(R.string.update_error_download_manager));
|
||||||
|
cleanupDownloadState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DownloadManager.Query query = new DownloadManager.Query();
|
||||||
|
query.setFilterById(downloadId);
|
||||||
|
Cursor cursor = downloadManager.query(query);
|
||||||
|
if (cursor != null) {
|
||||||
|
try {
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
int status = cursor.getInt(cursor.getColumnIndexOrThrow(
|
||||||
|
DownloadManager.COLUMN_STATUS));
|
||||||
|
if (status == DownloadManager.STATUS_SUCCESSFUL && downloadingFile != null) {
|
||||||
|
pendingInstallFile = downloadingFile;
|
||||||
|
Activity activity = activityRef != null ? activityRef.get() : null;
|
||||||
|
mainHandler.post(() -> {
|
||||||
|
showToast(appContext.getString(R.string.update_download_complete));
|
||||||
|
installDownloadedApk(activity, pendingInstallFile);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
int reason = cursor.getInt(cursor.getColumnIndexOrThrow(
|
||||||
|
DownloadManager.COLUMN_REASON));
|
||||||
|
mainHandler.post(() -> showToast(appContext.getString(
|
||||||
|
R.string.update_error_download_failed, reason)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cursor.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanupDownloadState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupDownloadState() {
|
||||||
|
unregisterDownloadReceiver();
|
||||||
|
currentDownloadId = -1L;
|
||||||
|
downloadingFile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postAvailable(UpdateCallback callback, UpdateInfo info) {
|
||||||
|
if (callback == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mainHandler.post(() -> callback.onUpdateAvailable(info));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postUpToDate(UpdateCallback callback) {
|
||||||
|
if (callback == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mainHandler.post(callback::onUpToDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void postError(UpdateCallback callback, String message) {
|
||||||
|
if (callback == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mainHandler.post(() -> callback.onError(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showToast(String message) {
|
||||||
|
mainHandler.post(() -> Toast.makeText(appContext, message, Toast.LENGTH_LONG).show());
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DownloadReceiver extends BroadcastReceiver {
|
||||||
|
@Override
|
||||||
|
public void onReceive(Context context, Intent intent) {
|
||||||
|
long id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L);
|
||||||
|
handleDownloadComplete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface UpdateCallback {
|
||||||
|
void onUpdateAvailable(UpdateInfo info);
|
||||||
|
|
||||||
|
void onUpToDate();
|
||||||
|
|
||||||
|
void onError(String message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class UpdateInfo {
|
||||||
|
public final int versionCode;
|
||||||
|
public final String versionName;
|
||||||
|
public final String releaseNotes;
|
||||||
|
public final String downloadUrl;
|
||||||
|
public final String downloadFileName;
|
||||||
|
public final long downloadSizeBytes;
|
||||||
|
public final int downloadCount;
|
||||||
|
public final String releasePageUrl;
|
||||||
|
public final int minSupportedVersionCode;
|
||||||
|
public final boolean forceUpdate;
|
||||||
|
|
||||||
|
UpdateInfo(int versionCode,
|
||||||
|
String versionName,
|
||||||
|
String releaseNotes,
|
||||||
|
String downloadUrl,
|
||||||
|
String downloadFileName,
|
||||||
|
long downloadSizeBytes,
|
||||||
|
int downloadCount,
|
||||||
|
String releasePageUrl,
|
||||||
|
int minSupportedVersionCode,
|
||||||
|
boolean forceUpdate) {
|
||||||
|
this.versionCode = versionCode;
|
||||||
|
this.versionName = versionName;
|
||||||
|
this.releaseNotes = releaseNotes == null ? "" : releaseNotes.trim();
|
||||||
|
this.downloadUrl = downloadUrl;
|
||||||
|
this.downloadFileName = downloadFileName;
|
||||||
|
this.downloadSizeBytes = downloadSizeBytes;
|
||||||
|
this.downloadCount = downloadCount;
|
||||||
|
this.releasePageUrl = releasePageUrl;
|
||||||
|
this.minSupportedVersionCode = minSupportedVersionCode;
|
||||||
|
this.forceUpdate = forceUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUpdateAvailable(int currentVersionCode) {
|
||||||
|
return versionCode > currentVersionCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMandatory(int currentVersionCode) {
|
||||||
|
return forceUpdate || currentVersionCode < minSupportedVersionCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReleaseNotesPreview() {
|
||||||
|
if (TextUtils.isEmpty(releaseNotes)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
final int limit = 900;
|
||||||
|
if (releaseNotes.length() <= limit) {
|
||||||
|
return releaseNotes;
|
||||||
|
}
|
||||||
|
return releaseNotes.substring(0, limit) + "\n…";
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getResolvedFileName() {
|
||||||
|
if (!TextUtils.isEmpty(downloadFileName)) {
|
||||||
|
return downloadFileName;
|
||||||
|
}
|
||||||
|
String safeVersion = TextUtils.isEmpty(versionName) ? String.valueOf(versionCode)
|
||||||
|
: versionName.replaceAll("[^0-9a-zA-Z._-]", "");
|
||||||
|
return "StreamPlayer-" + safeVersion + ".apk";
|
||||||
|
}
|
||||||
|
|
||||||
|
public String formatSize(Context context) {
|
||||||
|
if (downloadSizeBytes <= 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return Formatter.formatShortFileSize(context, downloadSizeBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
6
app/src/main/res/color/section_text_selector.xml
Normal file
6
app/src/main/res/color/section_text_selector.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:state_selected="true" android:color="@color/white" />
|
||||||
|
<item android:state_focused="true" android:color="@color/white" />
|
||||||
|
<item android:color="@color/text_secondary" />
|
||||||
|
</selector>
|
||||||
18
app/src/main/res/drawable/banner_streamplayer.xml
Normal file
18
app/src/main/res/drawable/banner_streamplayer.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<gradient
|
||||||
|
android:angle="0"
|
||||||
|
android:endColor="#FF002766"
|
||||||
|
android:startColor="#FF0F4C81" />
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<bitmap
|
||||||
|
android:antialias="true"
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/ic_launcher" />
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<solid android:color="#33212121" />
|
|
||||||
<corners android:radius="12dp" />
|
|
||||||
<stroke
|
|
||||||
android:width="1dp"
|
|
||||||
android:color="#55FFFFFF" />
|
|
||||||
</shape>
|
|
||||||
39
app/src/main/res/drawable/bg_channel_item_selector.xml
Normal file
39
app/src/main/res/drawable/bg_channel_item_selector.xml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:state_selected="true">
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#88003C8F" />
|
||||||
|
<corners android:radius="18dp" />
|
||||||
|
<stroke
|
||||||
|
android:width="3dp"
|
||||||
|
android:color="#FFFFFFFF" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item android:state_focused="true">
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#55003C8F" />
|
||||||
|
<corners android:radius="18dp" />
|
||||||
|
<stroke
|
||||||
|
android:width="3dp"
|
||||||
|
android:color="#FFFFFFFF" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item android:state_pressed="true">
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#55003C8F" />
|
||||||
|
<corners android:radius="18dp" />
|
||||||
|
<stroke
|
||||||
|
android:width="3dp"
|
||||||
|
android:color="#FFFFFFFF" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#33212121" />
|
||||||
|
<corners android:radius="18dp" />
|
||||||
|
<stroke
|
||||||
|
android:width="2dp"
|
||||||
|
android:color="#33FFFFFF" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</selector>
|
||||||
6
app/src/main/res/drawable/bg_section_indicator.xml
Normal file
6
app/src/main/res/drawable/bg_section_indicator.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:state_selected="true"><shape android:shape="rectangle"><solid android:color="#202020"/></shape></item>
|
||||||
|
<item android:state_focused="true"><shape android:shape="rectangle"><solid android:color="#303030"/></shape></item>
|
||||||
|
<item><shape android:shape="rectangle"><solid android:color="@android:color/transparent"/></shape></item>
|
||||||
|
</selector>
|
||||||
33
app/src/main/res/drawable/bg_tab_selector.xml
Normal file
33
app/src/main/res/drawable/bg_tab_selector.xml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:state_selected="true">
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#18d763" />
|
||||||
|
<corners android:radius="20dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item android:state_focused="true">
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#5522c1ff" />
|
||||||
|
<corners android:radius="20dp" />
|
||||||
|
<stroke
|
||||||
|
android:width="2dp"
|
||||||
|
android:color="#88FFFFFF" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item android:state_pressed="true">
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#3322c1ff" />
|
||||||
|
<corners android:radius="20dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#222222" />
|
||||||
|
<corners android:radius="20dp" />
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="#44FFFFFF" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</selector>
|
||||||
@@ -7,32 +7,106 @@
|
|||||||
android:background="@color/black"
|
android:background="@color/black"
|
||||||
tools:context=".MainActivity">
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:id="@+id/title"
|
android:id="@+id/nav_panel"
|
||||||
android:layout_width="0dp"
|
android:layout_width="180dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="0dp"
|
||||||
android:layout_marginStart="16dp"
|
android:orientation="vertical"
|
||||||
android:layout_marginTop="24dp"
|
android:paddingStart="16dp"
|
||||||
android:layout_marginEnd="16dp"
|
android:paddingTop="32dp"
|
||||||
android:text="Selecciona un canal"
|
android:paddingEnd="16dp"
|
||||||
android:textColor="@color/white"
|
android:paddingBottom="32dp"
|
||||||
android:textSize="22sp"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
android:textStyle="bold"
|
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/app_brand"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/app_name"
|
||||||
|
android:textAllCaps="true"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/app_tagline"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/home_tagline"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/channel_grid"
|
android:id="@+id/section_list"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:overScrollMode="never"
|
||||||
|
tools:listitem="@layout/item_section" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/divider"
|
||||||
|
android:layout_width="1dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:background="#33FFFFFF"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/nav_panel"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/content_panel"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_margin="12dp"
|
android:orientation="vertical"
|
||||||
android:clipToPadding="false"
|
android:paddingStart="24dp"
|
||||||
android:paddingBottom="12dp"
|
android:paddingTop="32dp"
|
||||||
|
android:paddingEnd="24dp"
|
||||||
|
android:paddingBottom="32dp"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toEndOf="@id/divider"
|
||||||
app:layout_constraintTop_toBottomOf="@id/title"
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/content_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
tools:text="Canales" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/loading_indicator"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/message_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:text="Mensaje" />
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/content_list"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:overScrollMode="never"
|
||||||
|
android:nextFocusLeft="@id/section_list"
|
||||||
tools:listitem="@layout/item_channel" />
|
tools:listitem="@layout/item_channel" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_margin="6dp"
|
android:layout_margin="6dp"
|
||||||
android:background="@drawable/bg_channel_item"
|
android:background="@drawable/bg_channel_item_selector"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="true"
|
||||||
|
android:defaultFocusHighlightEnabled="true"
|
||||||
android:gravity="center_horizontal"
|
android:gravity="center_horizontal"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="16dp">
|
android:padding="16dp">
|
||||||
|
|||||||
56
app/src/main/res/layout/item_event.xml
Normal file
56
app/src/main/res/layout/item_event.xml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="8dp"
|
||||||
|
android:background="@drawable/bg_channel_item_selector"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/event_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
tools:text="Partido" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/event_time"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="14sp"
|
||||||
|
tools:text="20:00" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/event_channel"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="14sp"
|
||||||
|
tools:text="ESPN" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/event_status"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingStart="8dp"
|
||||||
|
android:textColor="#18d763"
|
||||||
|
android:textSize="14sp"
|
||||||
|
tools:text="En vivo" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
23
app/src/main/res/layout/item_section.xml
Normal file
23
app/src/main/res/layout/item_section.xml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="true"
|
||||||
|
android:background="@drawable/bg_section_indicator"
|
||||||
|
android:padding="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/section_title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/section_text_selector"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
tools:text="Canales" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
4
app/src/main/res/values-sw720dp/integers.xml
Normal file
4
app/src/main/res/values-sw720dp/integers.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<integer name="channel_grid_span">5</integer>
|
||||||
|
</resources>
|
||||||
15
app/src/main/res/values/arrays.xml
Normal file
15
app/src/main/res/values/arrays.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string-array name="channel_entries">
|
||||||
|
<item>Azteca Deportes</item>
|
||||||
|
<item>Canal 5 MX</item>
|
||||||
|
<item>Caliente TV MX</item>
|
||||||
|
<item>DAZN 1</item>
|
||||||
|
<item>DAZN 2</item>
|
||||||
|
<item>DAZN LaLiga</item>
|
||||||
|
<item>DSports</item>
|
||||||
|
<item>DSports 2</item>
|
||||||
|
<item>DSports Plus</item>
|
||||||
|
<item>ESPN</item>
|
||||||
|
</string-array>
|
||||||
|
</resources>
|
||||||
@@ -2,4 +2,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<color name="black">#FF000000</color>
|
<color name="black">#FF000000</color>
|
||||||
<color name="white">#FFFFFFFF</color>
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
<color name="text_secondary">#B3FFFFFF</color>
|
||||||
</resources>
|
</resources>
|
||||||
4
app/src/main/res/values/integers.xml
Normal file
4
app/src/main/res/values/integers.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<integer name="channel_grid_span">3</integer>
|
||||||
|
</resources>
|
||||||
@@ -1,3 +1,39 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">StreamPlayer</string>
|
<string name="app_name">StreamPlayer</string>
|
||||||
|
<string name="home_tagline">Todo el deporte en un solo lugar</string>
|
||||||
|
<string name="section_channels">Canales</string>
|
||||||
|
<string name="section_events">Eventos</string>
|
||||||
|
<string name="section_all_channels">Todos los canales</string>
|
||||||
|
<string name="message_no_channels">No hay canales disponibles</string>
|
||||||
|
<string name="message_no_events">No hay eventos disponibles</string>
|
||||||
|
<string name="message_events_error">No se pudieron cargar los eventos: %1$s</string>
|
||||||
|
<string name="update_required_title">Actualización obligatoria</string>
|
||||||
|
<string name="update_available_title">Actualización disponible</string>
|
||||||
|
<string name="update_action_download">Actualizar ahora</string>
|
||||||
|
<string name="update_action_view_release">Ver detalles</string>
|
||||||
|
<string name="update_action_close_app">Cerrar aplicación</string>
|
||||||
|
<string name="update_action_later">Más tarde</string>
|
||||||
|
<string name="update_current_version">Versión instalada: %1$s (%2$d)</string>
|
||||||
|
<string name="update_latest_version">Última versión publicada: %1$s (%2$d)</string>
|
||||||
|
<string name="update_min_supported">Versiones anteriores a %1$d ya no están permitidas.</string>
|
||||||
|
<string name="update_download_size">Tamaño aproximado: %1$s</string>
|
||||||
|
<string name="update_downloads">Descargas registradas: %1$d</string>
|
||||||
|
<string name="update_release_notes_title">Novedades</string>
|
||||||
|
<string name="update_optional_hint">Puedes continuar usando la app, pero recomendamos instalar la actualización para obtener el mejor rendimiento.</string>
|
||||||
|
<string name="update_error_checking">No se pudo verificar actualizaciones (%1$s)</string>
|
||||||
|
<string name="update_error_open_release">No se pudo abrir el detalle de la versión</string>
|
||||||
|
<string name="update_error_empty_response">Respuesta vacía del servidor de releases</string>
|
||||||
|
<string name="update_error_http">Error de red (%1$d)</string>
|
||||||
|
<string name="update_error_missing_url">No se encontró URL de descarga</string>
|
||||||
|
<string name="update_error_download_manager">DownloadManager no está disponible en este dispositivo</string>
|
||||||
|
<string name="update_error_storage">No se pudo preparar el almacenamiento para la actualización</string>
|
||||||
|
<string name="update_error_download">Error al iniciar la descarga: %1$s</string>
|
||||||
|
<string name="update_download_started">Descarga iniciada, revisa la notificación para ver el progreso</string>
|
||||||
|
<string name="update_download_complete">Descarga finalizada, preparando instalación…</string>
|
||||||
|
<string name="update_error_download_failed">La descarga falló (código %1$d)</string>
|
||||||
|
<string name="update_error_install_permissions">No se pudo abrir la configuración de instalación desconocida</string>
|
||||||
|
<string name="update_permission_request">Habilita "Instalar apps desconocidas" para StreamPlayer y regresa para continuar.</string>
|
||||||
|
<string name="update_error_install_intent">No se pudo abrir el instalador de paquetes</string>
|
||||||
|
<string name="update_notification_title">StreamPlayer %1$s</string>
|
||||||
|
<string name="update_notification_description">Descargando nueva versión</string>
|
||||||
</resources>
|
</resources>
|
||||||
6
app/src/main/res/xml/file_paths.xml
Normal file
6
app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-files-path
|
||||||
|
name="updates"
|
||||||
|
path="." />
|
||||||
|
</paths>
|
||||||
10
release/update-manifest.example.json
Normal file
10
release/update-manifest.example.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"versionCode": 90000,
|
||||||
|
"versionName": "9.0.0",
|
||||||
|
"minSupportedVersionCode": 80000,
|
||||||
|
"forceUpdate": false,
|
||||||
|
"downloadUrl": "https://gitea.cbcren.online/renato97/app/releases/download/v9.0/StreamPlayer-v9.0-DefinitiveEdition.apk",
|
||||||
|
"fileName": "StreamPlayer-v9.0-DefinitiveEdition.apk",
|
||||||
|
"sizeBytes": 12000000,
|
||||||
|
"notes": "Texto opcional si necesitas personalizar las notas que verá el usuario"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user