init: EasyScreen 电子菜单系统

- server: FastAPI 后端(多屏管理、素材上传、节目编排、客户端注册/配置/心跳/崩溃上报、管理台托管)
- android: Java 客户端(minSdk 23,全屏图片/视频轮播、远程配置、开机自启、崩溃上报)
- web: React + Vite + antd 管理台(屏幕/素材/节目管理)
- 屏幕设备 ID 关联机制、gunicorn 生产部署脚本
This commit is contained in:
Tatta
2026-08-12 20:57:55 +08:00
commit 4e2775c8c3
58 changed files with 6451 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.easyscreen.player'
compileSdk 36
defaultConfig {
applicationId "com.easyscreen.player"
minSdk 23 // Android 6.0
targetSdk 36
versionCode 1
versionName "1.0.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
// 统一 Kotlin 标准库版本,解决 okhttp(4.x)/glide 传递依赖导致的重复类冲突
configurations.all {
resolutionStrategy {
force 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
force 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22'
force 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22'
}
}
dependencies {
// androidx 注解(@Nullable 等)
implementation 'androidx.annotation:annotation:1.6.0'
// 网络
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
// JSON
implementation 'com.google.code.gson:gson:2.10.1'
// 图片加载(本地缓存,断网仍可显示历史图片)
implementation 'com.github.bumptech.glide:glide:4.15.1'
// 视频播放(ExoPlayer 2.x,兼容 API 16+,老设备友好)
implementation 'com.google.android.exoplayer:exoplayer-core:2.19.1'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.19.1'
}
+2
View File
@@ -0,0 +1,2 @@
# 保留 Gson 反射所需类
-keep class com.easyscreen.player.api.** { *; }
+51
View File
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- 电子屏设备:允许明文 HTTP 访问内网服务器 -->
<application
android:name=".App"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:usesCleartextTraffic="true"
android:theme="@style/AppTheme">
<!-- 主播放界面(全屏、横屏锁定) -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- 作为信息发布盒子时,可设为默认桌面开机直达 -->
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- 首次启动的服务器地址配置界面 -->
<activity
android:name=".SetupActivity"
android:exported="false"
android:screenOrientation="sensorLandscape" />
<!-- 开机自启 -->
<receiver
android:name=".receiver.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,15 @@
package com.easyscreen.player;
import android.app.Application;
import com.easyscreen.player.util.CrashHandler;
/** 应用入口:注册全局崩溃捕获(堆栈写入本地文件并上报后端)。 */
public class App extends Application {
@Override
public void onCreate() {
super.onCreate();
Thread.setDefaultUncaughtExceptionHandler(new CrashHandler(this));
}
}
@@ -0,0 +1,332 @@
package com.easyscreen.player;
import android.app.Activity;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.View;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.annotation.Nullable;
import com.bumptech.glide.Glide;
import com.easyscreen.player.api.ApiClient;
import com.easyscreen.player.api.ClientModels;
import com.easyscreen.player.util.DeviceId;
import com.easyscreen.player.util.Prefs;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.ui.PlayerView;
import java.util.ArrayList;
import java.util.List;
/**
* 电子菜单主播放界面:
* 1. 每次打开自动从远程加载配置(注册设备 -> 拉取配置)
* 2. 全屏轮播图片/视频
* 3. 心跳上报 + 定期重载配置(后台改动后自动生效)
*/
public class MainActivity extends Activity {
private static final long HEARTBEAT_INTERVAL_MS = 30_000L;
private static final long RELOAD_CONFIG_INTERVAL_MS = 10 * 60_000L;
private static final long RETRY_INTERVAL_MS = 30_000L;
private final Handler handler = new Handler(Looper.getMainLooper());
private ImageView imageView;
private PlayerView playerView;
private View statusOverlay;
private TextView statusText;
private ProgressBar progressBar;
private Prefs prefs;
private ApiClient api;
private String deviceId;
private ExoPlayer exoPlayer;
private List<ClientModels.ConfigItem> items = new ArrayList<>();
private int currentIndex = -1;
private long currentVersion = -1;
private boolean playing = false;
private final Runnable nextRunnable = this::next;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
prefs = new Prefs(this);
// 设备标识:优先使用配置的「屏幕 ID」(与后台屏幕关联),未配置则用设备硬件 ID 自动注册
String configuredScreenId = prefs.getScreenId();
deviceId = (configuredScreenId != null && !configuredScreenId.isEmpty())
? configuredScreenId
: DeviceId.get(this);
setupFullscreen();
buildUi();
String serverUrl = prefs.getServerUrl();
if (serverUrl.isEmpty()) {
// 首次启动:先去配置服务器地址
startActivity(new android.content.Intent(this, SetupActivity.class));
finish();
return;
}
api = new ApiClient(serverUrl);
startPlayback();
}
// ---------------- UI ----------------
private void buildUi() {
FrameLayout root = new FrameLayout(this);
imageView = new ImageView(this);
imageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
root.addView(imageView, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
playerView = new PlayerView(this);
playerView.setResizeMode(com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM);
playerView.setVisibility(View.GONE);
root.addView(playerView, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
// 状态覆盖层(加载中 / 等待配置 / 错误提示)
FrameLayout overlay = new FrameLayout(this);
overlay.setBackgroundColor(0x99000000);
overlay.setVisibility(View.GONE);
progressBar = new ProgressBar(this);
FrameLayout.LayoutParams pbLp = new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT);
pbLp.gravity = android.view.Gravity.CENTER;
overlay.addView(progressBar, pbLp);
statusText = new TextView(this);
statusText.setTextColor(0xFFFFFFFF);
statusText.setTextSize(22);
statusText.setGravity(android.view.Gravity.CENTER);
FrameLayout.LayoutParams stLp = new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT);
stLp.gravity = android.view.Gravity.CENTER;
stLp.topMargin = 120;
overlay.addView(statusText, stLp);
statusOverlay = overlay;
root.addView(overlay, new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
setContentView(root);
}
private void setupFullscreen() {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
hideSystemUi();
}
private void hideSystemUi() {
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) hideSystemUi();
}
// ---------------- 播放调度 ----------------
private void startPlayback() {
// 首次拉取配置(注册 + 拉配置)
fetchConfig();
// 心跳循环
handler.postDelayed(new Runnable() {
@Override
public void run() {
api.heartbeat(deviceId, new ApiClient.ApiCallback<ClientModels.OkResponse>() {
@Override
public void onSuccess(ClientModels.OkResponse result) { }
@Override
public void onError(String message) { }
});
handler.postDelayed(this, HEARTBEAT_INTERVAL_MS);
}
}, HEARTBEAT_INTERVAL_MS);
// 定期重载配置,后台变更自动生效
handler.postDelayed(new Runnable() {
@Override
public void run() {
fetchConfig();
handler.postDelayed(this, RELOAD_CONFIG_INTERVAL_MS);
}
}, RELOAD_CONFIG_INTERVAL_MS);
}
private void fetchConfig() {
api.register(deviceId, DeviceId.displayName(this), new ApiClient.ApiCallback<ClientModels.OkResponse>() {
@Override
public void onSuccess(ClientModels.OkResponse result) {
api.fetchConfig(deviceId, new ApiClient.ApiCallback<ClientModels.ClientConfig>() {
@Override
public void onSuccess(ClientModels.ClientConfig config) {
handleConfig(config);
}
@Override
public void onError(String message) {
showError("拉取配置失败: " + message);
scheduleRetry();
}
});
}
@Override
public void onError(String message) {
showError("连接服务器失败: " + message);
scheduleRetry();
}
});
}
private void handleConfig(ClientModels.ClientConfig config) {
if (!config.configured || config.items == null || config.items.isEmpty()) {
// 尚未绑定节目:显示等待提示,由定期重载自动生效
stopPlayback();
items.clear();
currentVersion = -1;
showWaiting("屏幕尚未配置节目,请到后台绑定");
return;
}
if (config.version == currentVersion && playing) {
return; // 配置未变化
}
items = config.items;
currentVersion = config.version;
currentIndex = -1;
hideOverlay();
playing = true;
next();
}
private void next() {
if (!playing || items.isEmpty()) return;
currentIndex = (currentIndex + 1) % items.size();
ClientModels.ConfigItem item = items.get(currentIndex);
if ("video".equals(item.type)) {
playVideo(item);
} else {
playImage(item);
}
}
private void playImage(ClientModels.ConfigItem item) {
releasePlayer();
playerView.setVisibility(View.GONE);
imageView.setVisibility(View.VISIBLE);
Glide.with(this)
.load(api.mediaUrl(item.url))
.into(imageView);
int durationMs = Math.max(item.duration, 3) * 1000; // 至少 3 秒
handler.removeCallbacks(nextRunnable);
handler.postDelayed(nextRunnable, durationMs);
}
private void playVideo(ClientModels.ConfigItem item) {
imageView.setVisibility(View.GONE);
releasePlayer();
exoPlayer = new ExoPlayer.Builder(this).build();
exoPlayer.setMediaItem(MediaItem.fromUri(api.mediaUrl(item.url)));
exoPlayer.addListener(new Player.Listener() {
@Override
public void onPlaybackStateChanged(int playbackState) {
if (playbackState == Player.STATE_ENDED) {
next();
}
}
});
playerView.setPlayer(exoPlayer);
playerView.setVisibility(View.VISIBLE);
exoPlayer.prepare();
exoPlayer.setPlayWhenReady(true);
// 若配置了展示时长(>0),视频也可定时强制切换
if (item.duration > 0) {
handler.removeCallbacks(nextRunnable);
handler.postDelayed(nextRunnable, item.duration * 1000L);
}
}
private void releasePlayer() {
handler.removeCallbacks(nextRunnable);
if (exoPlayer != null) {
exoPlayer.release();
exoPlayer = null;
playerView.setPlayer(null);
}
}
private void stopPlayback() {
playing = false;
releasePlayer();
imageView.setVisibility(View.GONE);
playerView.setVisibility(View.GONE);
}
// ---------------- 状态提示 ----------------
private void showWaiting(String message) {
progressBar.setVisibility(View.GONE);
statusText.setText(message);
statusOverlay.setVisibility(View.VISIBLE);
}
private void showError(String message) {
playing = false;
stopPlayback();
progressBar.setVisibility(View.GONE);
statusText.setText(message + "\n将自动重试...");
statusOverlay.setVisibility(View.VISIBLE);
}
private void hideOverlay() {
statusOverlay.setVisibility(View.GONE);
}
private void scheduleRetry() {
handler.removeCallbacks(retryRunnable);
handler.postDelayed(retryRunnable, RETRY_INTERVAL_MS);
}
private final Runnable retryRunnable = new Runnable() {
@Override
public void run() {
fetchConfig();
}
};
@Override
protected void onDestroy() {
super.onDestroy();
handler.removeCallbacksAndMessages(null);
releasePlayer();
// 注意:不要在 onDestroy 里调用 Glide.with(this)——Activity 已销毁时
// Glide.with(Activity) 会抛 IllegalArgumentExceptionGlide 会自动清理其资源)
}
}
@@ -0,0 +1,212 @@
package com.easyscreen.player;
import android.app.Activity;
import android.content.Intent;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.InputType;
import android.view.Gravity;
import android.view.View;
import android.widget.Button;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.Nullable;
import com.easyscreen.player.util.Prefs;
import org.json.JSONObject;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
/**
* 首次启动配置界面:输入后台服务器地址(如 http://192.168.1.100:5889)。
* 保存前会测试 /api/health 连通性,通过后才保存并进入播放界面。
*/
public class SetupActivity extends Activity {
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final Handler handler = new Handler(Looper.getMainLooper());
private EditText urlInput;
private EditText screenIdInput;
private CheckBox rememberCheck;
private Button saveButton;
private TextView hintText;
private boolean saving = false;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setGravity(Gravity.CENTER);
root.setPadding(80, 80, 80, 80);
root.setBackgroundColor(Color.BLACK);
TextView title = new TextView(this);
title.setText("EasyScreen 服务器配置");
title.setTextColor(Color.WHITE);
title.setTextSize(26);
title.setGravity(Gravity.CENTER);
root.addView(title, lp(true));
urlInput = new EditText(this);
urlInput.setHint("http://192.168.1.100:5889");
urlInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
urlInput.setTextColor(Color.WHITE);
urlInput.setHintTextColor(Color.GRAY);
urlInput.setSingleLine(true);
urlInput.setPadding(20, 20, 20, 20);
root.addView(urlInput, lp(true));
screenIdInput = new EditText(this);
screenIdInput.setHint("屏幕 ID(可选,如 screen-001");
screenIdInput.setTextColor(Color.WHITE);
screenIdInput.setHintTextColor(Color.GRAY);
screenIdInput.setSingleLine(true);
screenIdInput.setPadding(20, 20, 20, 20);
root.addView(screenIdInput, lp(true));
hintText = new TextView(this);
hintText.setText("请输入后端服务地址;若后台已建屏,可填写后台「屏幕管理」中的设备 ID 以关联该屏(留空则自动注册新屏)");
hintText.setTextColor(Color.LTGRAY);
hintText.setTextSize(14);
hintText.setGravity(Gravity.CENTER);
root.addView(hintText, lp(true));
rememberCheck = new CheckBox(this);
rememberCheck.setText("记住本次配置(下次打开免输入,直接进入播放)");
rememberCheck.setTextColor(Color.WHITE);
rememberCheck.setChecked(true);
root.addView(rememberCheck, lp(true));
saveButton = new Button(this);
saveButton.setText("保存并开始播放");
saveButton.setAllCaps(false);
saveButton.setOnClickListener(v -> onSaveClicked());
root.addView(saveButton, lp(true));
// 预填已保存的地址(重新进入时)
Prefs prefs = new Prefs(this);
String saved = prefs.getServerUrl();
if (!saved.isEmpty()) urlInput.setText(saved);
String savedId = prefs.getScreenId();
if (!savedId.isEmpty()) screenIdInput.setText(savedId);
setContentView(root);
}
private LinearLayout.LayoutParams lp(boolean horizontalMatch) {
return new LinearLayout.LayoutParams(
horizontalMatch ? LinearLayout.LayoutParams.MATCH_PARENT : LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT);
}
private void onSaveClicked() {
if (saving) return;
String url = urlInput.getText().toString().trim();
if (url.isEmpty()) {
Toast.makeText(this, "请输入服务器地址", Toast.LENGTH_SHORT).show();
return;
}
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "http://" + url;
}
url = url.replaceAll("/+$", "");
final String finalUrl = url;
final String finalScreenId = screenIdInput.getText().toString().trim();
final boolean remember = rememberCheck.isChecked();
// 提交型操作:立即 loading(禁用 + 提示),接口返回后复位
saving = true;
saveButton.setEnabled(false);
saveButton.setText("正在校验配置...");
hintText.setText("");
executor.execute(() -> {
boolean urlOk = testConnection(finalUrl);
boolean idOk = true;
if (urlOk && !finalScreenId.isEmpty()) {
idOk = checkScreenExists(finalUrl, finalScreenId);
}
final boolean fUrlOk = urlOk;
final boolean fIdOk = idOk;
handler.post(() -> {
saving = false;
saveButton.setEnabled(true);
saveButton.setText("保存并开始播放");
if (!fUrlOk) {
hintText.setText("无法连接该地址,请检查服务器是否已启动、地址是否正确(含端口)");
return;
}
if (!fIdOk) {
hintText.setText("屏幕 ID 不存在!请确认后台「屏幕管理」已创建该屏幕,且设备 ID 完全一致(含大小写)");
return;
}
if (remember) {
new Prefs(this).setServerUrl(finalUrl);
new Prefs(this).setScreenId(finalScreenId);
Toast.makeText(this, "配置已保存,下次打开免输入", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(this, "校验通过(本次有效,未保存)", Toast.LENGTH_SHORT).show();
}
startActivity(new Intent(this, MainActivity.class));
finish();
});
});
}
/** 校验屏幕 ID 是否在后台存在(同步)。接口异常时放行,避免误伤。 */
private boolean checkScreenExists(String baseUrl, String deviceId) {
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
.build();
Request request = new Request.Builder()
.url(baseUrl + "/api/client/check?device_id=" + deviceId)
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) return true;
String text = response.body() != null ? response.body().string() : "{}";
JSONObject json = new JSONObject(text);
return json.optBoolean("exists", true);
} catch (Exception e) {
return true; // 网络异常放行(URL 连通性已单独校验)
}
}
private boolean testConnection(String baseUrl) {
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
.build();
Request request = new Request.Builder().url(baseUrl + "/api/health").get().build();
try (Response response = client.newCall(request).execute()) {
return response.isSuccessful();
} catch (IOException e) {
return false;
}
}
@Override
protected void onDestroy() {
super.onDestroy();
executor.shutdownNow();
handler.removeCallbacksAndMessages(null);
}
}
@@ -0,0 +1,127 @@
package com.easyscreen.player.api;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.google.gson.Gson;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
/** 后端 HTTP 客户端封装(OkHttp 异步回调)。 */
public class ApiClient {
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
private static final Gson GSON = new Gson();
private static final int TIMEOUT_SECONDS = 15;
private final OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build();
private final String baseUrl; // 如 http://192.168.1.100:5889(无尾斜杠)
public ApiClient(String baseUrl) {
this.baseUrl = baseUrl.endsWith("/")
? baseUrl.substring(0, baseUrl.length() - 1)
: baseUrl;
}
public interface ApiCallback<T> {
void onSuccess(T result);
void onError(String message);
}
private <T> void get(String path, Class<T> clazz, ApiCallback<T> cb) {
Request request = new Request.Builder().url(baseUrl + path).get().build();
enqueue(request, clazz, cb);
}
private <T> void postJson(String path, Object body, Class<T> clazz, ApiCallback<T> cb) {
Request request = new Request.Builder()
.url(baseUrl + path)
.post(RequestBody.create(GSON.toJson(body), JSON))
.build();
enqueue(request, clazz, cb);
}
private <T> void enqueue(Request request, Class<T> clazz, ApiCallback<T> cb) {
client.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
postToMain(() -> cb.onError("网络错误: " + e.getMessage()));
}
@Override
public void onResponse(Call call, Response response) throws IOException {
try {
if (!response.isSuccessful()) {
postToMain(() -> cb.onError("服务器返回 " + response.code()));
return;
}
String text = response.body() != null ? response.body().string() : "{}";
final T result = clazz == String.class
? (T) text
: GSON.fromJson(text, clazz);
postToMain(() -> cb.onSuccess(result));
} catch (Exception e) {
postToMain(() -> cb.onError("解析失败: " + e.getMessage()));
} finally {
response.close();
}
}
});
}
/** 将回调切换到主线程执行(OkHttp 回调默认在 IO 线程,不能直接操作 UI)。 */
private void postToMain(Runnable runnable) {
new Handler(Looper.getMainLooper()).post(runnable);
}
/** 设备注册(幂等)。 */
public void register(String deviceId, String deviceName, ApiCallback<ClientModels.OkResponse> cb) {
// 后端 register 返回 {screen_id, registered},此处不关心具体字段,仅确认成功
postJson("/api/client/register", new ClientModels.RegisterRequest(deviceId, deviceName), ClientModels.OkResponse.class, cb);
}
/** 拉取播放配置。 */
public void fetchConfig(String deviceId, ApiCallback<ClientModels.ClientConfig> cb) {
get("/api/client/config?device_id=" + deviceId, ClientModels.ClientConfig.class, cb);
}
/** 心跳上报。 */
public void heartbeat(String deviceId, ApiCallback<ClientModels.OkResponse> cb) {
postJson("/api/client/heartbeat", new ClientModels.HeartbeatRequest(deviceId), ClientModels.OkResponse.class, cb);
}
/** 崩溃上报(同步,短超时,确保 App 退出前请求发出)。 */
public void reportCrash(ClientModels.CrashReportRequest body) throws IOException {
Request request = new Request.Builder()
.url(baseUrl + "/api/client/crash")
.post(RequestBody.create(GSON.toJson(body), JSON))
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) {
Log.d("EasyScreen", "crash report failed: " + response.code());
}
}
}
/** 拼接素材完整地址。 */
public String mediaUrl(String relativeUrl) {
return baseUrl + relativeUrl;
}
}
@@ -0,0 +1,73 @@
package com.easyscreen.player.api;
import com.google.gson.annotations.SerializedName;
import java.util.List;
/** 与后端 /api/client 接口对应的数据模型(字段名与后端 JSON 一致)。 */
public class ClientModels {
/** 配置项:单个图片或视频。 */
public static class ConfigItem {
@SerializedName("asset_id")
public long assetId;
@SerializedName("type")
public String type; // image / video
@SerializedName("url")
public String url; // 相对路径,如 /media/xxx.png
@SerializedName("name")
public String name;
@SerializedName("duration")
public int duration; // 秒;视频为 0 表示按视频自身时长
}
/** GET /api/client/config 响应。 */
public static class ClientConfig {
@SerializedName("configured")
public boolean configured;
@SerializedName("playlist_id")
public Long playlistId;
@SerializedName("playlist_name")
public String playlistName;
@SerializedName("version")
public long version;
@SerializedName("items")
public List<ConfigItem> items;
}
/** POST /api/client/register 请求。 */
public static class RegisterRequest {
@SerializedName("device_id")
public String deviceId;
@SerializedName("name")
public String name;
public RegisterRequest(String deviceId, String name) {
this.deviceId = deviceId;
this.name = name;
}
}
/** POST /api/client/heartbeat 请求。 */
public static class HeartbeatRequest {
@SerializedName("device_id")
public String deviceId;
public HeartbeatRequest(String deviceId) {
this.deviceId = deviceId;
}
}
public static class OkResponse {
@SerializedName("ok")
public boolean ok;
}
/** POST /api/client/crash 请求。 */
public static class CrashReportRequest {
@SerializedName("device_id")
public String deviceId;
@SerializedName("stacktrace")
public String stacktrace;
}
}
@@ -0,0 +1,20 @@
package com.easyscreen.player.receiver;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import com.easyscreen.player.MainActivity;
/** 开机自启:电子屏设备上电后自动进入播放界面。 */
public class BootReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
Intent launch = new Intent(context, MainActivity.class);
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(launch);
}
}
}
@@ -0,0 +1,71 @@
package com.easyscreen.player.util;
import android.content.Context;
import android.util.Log;
import com.easyscreen.player.api.ApiClient;
import com.easyscreen.player.api.ClientModels;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
/**
* 全局崩溃捕获:
* 1. 堆栈写入应用私有目录 crash.log(无需权限)
* 2. 若已配置服务器地址,同步上报到后端 /api/client/crash(远程排查显示屏异常)
*/
public class CrashHandler implements Thread.UncaughtExceptionHandler {
private final Context appContext;
private final Thread.UncaughtExceptionHandler defaultHandler;
public CrashHandler(Context context) {
this.appContext = context.getApplicationContext();
this.defaultHandler = Thread.getDefaultUncaughtExceptionHandler();
}
@Override
public void uncaughtException(Thread thread, Throwable throwable) {
try {
String stack = Log.getStackTraceString(throwable);
saveToFile(stack);
reportToServer(stack);
} catch (Throwable ignored) {
// 上报过程自身异常不影响后续
}
if (defaultHandler != null) {
defaultHandler.uncaughtException(thread, throwable);
} else {
android.os.Process.killProcess(android.os.Process.myPid());
}
}
private void saveToFile(String stack) {
try {
File file = new File(appContext.getFilesDir(), "crash.log");
FileWriter writer = new FileWriter(file, true);
String ts = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(new Date());
writer.write("===== " + ts + " =====\n" + stack + "\n");
writer.close();
} catch (IOException ignored) {
}
}
private void reportToServer(String stack) {
Prefs prefs = new Prefs(appContext);
String serverUrl = prefs.getServerUrl();
if (serverUrl.isEmpty()) return;
try {
ApiClient api = new ApiClient(serverUrl);
ClientModels.CrashReportRequest req = new ClientModels.CrashReportRequest();
req.deviceId = DeviceId.get(appContext);
req.stacktrace = stack;
api.reportCrash(req);
} catch (Throwable ignored) {
}
}
}
@@ -0,0 +1,25 @@
package com.easyscreen.player.util;
import android.content.Context;
import android.os.Build;
import android.provider.Settings;
/** 设备唯一标识:优先 ANDROID_ID(无需权限,持久稳定)。 */
public class DeviceId {
public static String get(Context context) {
String androidId = Settings.Secure.getString(
context.getContentResolver(), Settings.Secure.ANDROID_ID);
if (androidId != null && !androidId.isEmpty()) {
return androidId;
}
// 极少数设备 ANDROID_ID 为空时的兜底
String fallback = Build.BOARD + "-" + Build.BRAND + "-" + Build.DEVICE;
return String.valueOf(fallback.hashCode() & 0x7fffffff);
}
/** 设备显示名(注册到后台,便于管理台识别)。 */
public static String displayName(Context context) {
return Build.MODEL;
}
}
@@ -0,0 +1,43 @@
package com.easyscreen.player.util;
import android.content.Context;
import android.content.SharedPreferences;
/** 本地偏好存储:服务器地址等。 */
public class Prefs {
private static final String NAME = "easyscreen_prefs";
private static final String KEY_SERVER_URL = "server_url";
private static final String KEY_SCREEN_ID = "screen_id";
private static final String KEY_DEVICE_NAME = "device_name";
private final SharedPreferences sp;
public Prefs(Context context) {
sp = context.getSharedPreferences(NAME, Context.MODE_PRIVATE);
}
public String getServerUrl() {
return sp.getString(KEY_SERVER_URL, "");
}
public void setServerUrl(String url) {
sp.edit().putString(KEY_SERVER_URL, url.trim()).apply();
}
public String getDeviceName() {
return sp.getString(KEY_DEVICE_NAME, "");
}
public void setDeviceName(String name) {
sp.edit().putString(KEY_DEVICE_NAME, name).apply();
}
public String getScreenId() {
return sp.getString(KEY_SCREEN_ID, "");
}
public void setScreenId(String screenId) {
sp.edit().putString(KEY_SCREEN_ID, screenId == null ? "" : screenId.trim()).apply();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">EasyScreen</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 全屏黑底主题(使用原生主题,无需 AppCompat) -->
<style name="AppTheme" parent="android:Theme.Black.NoTitleBar.Fullscreen">
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>