init: EasyScreen 电子菜单系统
- server: FastAPI 后端(多屏管理、素材上传、节目编排、客户端注册/配置/心跳/崩溃上报、管理台托管) - android: Java 客户端(minSdk 23,全屏图片/视频轮播、远程配置、开机自启、崩溃上报) - web: React + Vite + antd 管理台(屏幕/素材/节目管理) - 屏幕设备 ID 关联机制、gunicorn 生产部署脚本
This commit is contained in:
@@ -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'
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
# 保留 Gson 反射所需类
|
||||
-keep class com.easyscreen.player.api.** { *; }
|
||||
@@ -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) 会抛 IllegalArgumentException(Glide 会自动清理其资源)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user