Add v7.0: Tabs UI and Real-Time Events

Major Features:
- Dual tab interface (Channels and Events) with visible focus
- Real-time event status calculation (Live, Upcoming, Finished)
- Smart caching system for events (24-hour cache)
- Argentina timezone support (America/Argentina/Buenos_Aires)

UI/TV Improvements:
- Focusable tabs with bg_tab_selector for D-pad navigation
- Visual feedback with highlighted borders on focused tabs
- Consistent design between tabs and content cards
- Enhanced TV navigation experience

Real-Time Event System:
- EventRepository: Centralized event management with 24h cache
- EventAdapter: Optimized RecyclerView for event listings
- EventItem: Structured data model for events
- Dynamic status calculation (remaining time, live duration, completion)
- Automatic link normalization to global2.php

Technical Implementation:
- activity_main.xml: Complete dual-tab layout
- item_event.xml: Dedicated event item layout with RecyclerView
- bg_tab_selector.xml: Tab states (selected, focused, pressed)
- MainActivity.java: Tab switching and event management
- Automatic URL processing for seamless PlayerActivity integration

Time Zone Features:
- Argentina local time (America/Argentina/Buenos_Aires)
- Real-time status updates without page refresh
- "En Xh Ym" for upcoming events
- "En vivo durante 2h" status for live events
- "Finalizado" status for completed events

Solutions:
- Fixed web page "En vivo" not updating issue
- Provides always-current event status in app
- Direct event-to-player navigation without manual intervention
- Improved TV navigation with clear visual feedback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-14 21:44:04 +00:00
parent ab43ce7794
commit 96c4c360ee
7 changed files with 556 additions and 13 deletions

View 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";
}
}
}

View 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;
}
}

View 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;
}
}
}

View File

@@ -2,31 +2,112 @@ package com.streamplayer;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import java.util.List;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
private RecyclerView channelGrid;
private RecyclerView eventsList;
private View eventsContainer;
private ProgressBar eventsProgress;
private TextView eventsError;
private Button tabChannels;
private Button tabEvents;
private EventAdapter eventAdapter;
private EventRepository eventRepository;
private boolean eventsLoaded = false;
@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); channelGrid = findViewById(R.id.channel_grid);
recyclerView.setLayoutManager(new GridLayoutManager(this, getSpanCount())); eventsList = findViewById(R.id.events_list);
recyclerView.setHasFixedSize(true); eventsContainer = findViewById(R.id.events_container);
ChannelAdapter adapter = new ChannelAdapter( eventsProgress = findViewById(R.id.events_progress);
eventsError = findViewById(R.id.events_error);
tabChannels = findViewById(R.id.tab_channels);
tabEvents = findViewById(R.id.tab_events);
channelGrid.setLayoutManager(new GridLayoutManager(this, getSpanCount()));
channelGrid.setHasFixedSize(true);
ChannelAdapter channelAdapter = new ChannelAdapter(
ChannelRepository.getChannels(), ChannelRepository.getChannels(),
channel -> { channel -> openPlayer(channel.getName(), channel.getPageUrl()));
Intent intent = new Intent(MainActivity.this, PlayerActivity.class); channelGrid.setAdapter(channelAdapter);
intent.putExtra(PlayerActivity.EXTRA_CHANNEL_NAME, channel.getName());
intent.putExtra(PlayerActivity.EXTRA_CHANNEL_URL, channel.getPageUrl()); eventsList.setLayoutManager(new GridLayoutManager(this, 1));
startActivity(intent); eventAdapter = new EventAdapter(event -> openPlayer(event.getTitle(), event.getPageUrl()));
eventsList.setAdapter(eventAdapter);
eventRepository = new EventRepository();
tabChannels.setOnClickListener(v -> showChannels());
tabEvents.setOnClickListener(v -> showEvents());
showChannels();
channelGrid.post(channelGrid::requestFocus);
}
private void showChannels() {
channelGrid.setVisibility(View.VISIBLE);
eventsContainer.setVisibility(View.GONE);
tabChannels.setSelected(true);
tabEvents.setSelected(false);
}
private void showEvents() {
channelGrid.setVisibility(View.GONE);
eventsContainer.setVisibility(View.VISIBLE);
tabChannels.setSelected(false);
tabEvents.setSelected(true);
if (!eventsLoaded) {
loadEvents(false);
}
}
private void loadEvents(boolean forceRefresh) {
eventsProgress.setVisibility(View.VISIBLE);
eventsError.setVisibility(View.GONE);
eventsList.setVisibility(View.GONE);
eventRepository.loadEvents(this, forceRefresh, new EventRepository.Callback() {
@Override
public void onSuccess(List<EventItem> events) {
runOnUiThread(() -> {
eventsProgress.setVisibility(View.GONE);
eventAdapter.submitList(events);
eventsList.setVisibility(View.VISIBLE);
eventsLoaded = true;
}); });
recyclerView.setAdapter(adapter); }
recyclerView.post(recyclerView::requestFocus);
@Override
public void onError(String message) {
runOnUiThread(() -> {
eventsProgress.setVisibility(View.GONE);
eventsError.setText("No se pudieron cargar los eventos: " + message);
eventsError.setVisibility(View.VISIBLE);
});
}
});
}
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 int getSpanCount() { private int getSpanCount() {

View 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>

View File

@@ -14,7 +14,7 @@
android:layout_marginStart="16dp" android:layout_marginStart="16dp"
android:layout_marginTop="24dp" android:layout_marginTop="24dp"
android:layout_marginEnd="16dp" android:layout_marginEnd="16dp"
android:text="Selecciona un canal" android:text="StreamPlayer"
android:textColor="@color/white" android:textColor="@color/white"
android:textSize="22sp" android:textSize="22sp"
android:textStyle="bold" android:textStyle="bold"
@@ -22,6 +22,45 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:id="@+id/tabs_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:gravity="center"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title">
<Button
android:id="@+id/tab_channels"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:background="@drawable/bg_tab_selector"
android:focusable="true"
android:focusableInTouchMode="true"
android:text="Canales"
android:textAllCaps="false" />
<Button
android:id="@+id/tab_events"
style="?android:attr/buttonBarButtonStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@drawable/bg_tab_selector"
android:focusable="true"
android:focusableInTouchMode="true"
android:text="Eventos"
android:textAllCaps="false" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/channel_grid" android:id="@+id/channel_grid"
android:layout_width="0dp" android:layout_width="0dp"
@@ -32,7 +71,47 @@
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_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title" app:layout_constraintTop_toBottomOf="@id/tabs_container"
tools:listitem="@layout/item_channel" /> tools:listitem="@layout/item_channel" />
<LinearLayout
android:id="@+id/events_container"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="12dp"
android:orientation="vertical"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tabs_container">
<ProgressBar
android:id="@+id/events_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<TextView
android:id="@+id/events_error"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="16dp"
android:textColor="@color/white"
android:textSize="16sp"
android:visibility="gone"
tools:text="No se pudieron cargar los eventos" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/events_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:paddingBottom="12dp"
tools:listitem="@layout/item_event" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View 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>