Merge branch 'master' of https://gitee.com/xiao12feng/zhibo_1 into merge/unify-all

# Conflicts:
#	android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java
#	android-app/app/src/main/java/com/example/livestreaming/RoomsAdapter.java
#	android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java
This commit is contained in:
xiao12feng8 2025-12-23 18:32:51 +08:00
commit bc53b6c482
46 changed files with 2907 additions and 650 deletions

View File

@ -138,6 +138,16 @@
android:configChanges="orientation|screenSize|keyboardHidden"
android:screenOrientation="portrait" />
<activity
android:name="com.example.livestreaming.LoginActivity"
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name="com.example.livestreaming.RegisterActivity"
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name="com.example.livestreaming.MainActivity"
android:exported="true"

View File

@ -0,0 +1,91 @@
package com.example.livestreaming;
import android.content.Context;
import android.text.TextUtils;
import android.widget.Toast;
import androidx.appcompat.app.AlertDialog;
import com.example.livestreaming.net.AuthStore;
/**
* 登录检查工具类
* 用于在需要登录的功能处检查登录状态如果未登录则提示用户并跳转到登录页面
*/
public class AuthHelper {
/**
* 检查是否已登录
* @param context 上下文
* @return true表示已登录false表示未登录
*/
public static boolean isLoggedIn(Context context) {
if (context == null) return false;
String token = AuthStore.getToken(context);
return !TextUtils.isEmpty(token);
}
/**
* 检查登录状态如果未登录则提示用户并跳转到登录页面
* @param context 上下文必须是Activity
* @param message 提示消息可选如果为null则使用默认消息
* @return true表示已登录false表示未登录已跳转到登录页面
*/
public static boolean requireLogin(Context context, String message) {
if (isLoggedIn(context)) {
return true;
}
// 未登录显示提示对话框
String tipMessage = message != null ? message : "此功能需要登录后使用,请先登录";
new AlertDialog.Builder(context)
.setTitle("需要登录")
.setMessage(tipMessage)
.setNegativeButton("取消", null)
.setPositiveButton("去登录", (dialog, which) -> {
// 跳转到登录页面
LoginActivity.start(context);
})
.show();
return false;
}
/**
* 检查登录状态如果未登录则提示用户并跳转到登录页面使用默认提示消息
* @param context 上下文必须是Activity
* @return true表示已登录false表示未登录已跳转到登录页面
*/
public static boolean requireLogin(Context context) {
return requireLogin(context, null);
}
/**
* 检查登录状态如果未登录则显示Toast提示并跳转到登录页面
* @param context 上下文必须是Activity
* @param toastMessage Toast提示消息可选如果为null则使用默认消息
* @return true表示已登录false表示未登录已跳转到登录页面
*/
public static boolean requireLoginWithToast(Context context, String toastMessage) {
if (isLoggedIn(context)) {
return true;
}
// 未登录显示Toast提示并跳转到登录页面
String message = toastMessage != null ? toastMessage : "此功能需要登录后使用";
Toast.makeText(context, message, Toast.LENGTH_SHORT).show();
LoginActivity.start(context);
return false;
}
/**
* 检查登录状态如果未登录则显示Toast提示并跳转到登录页面使用默认提示消息
* @param context 上下文必须是Activity
* @return true表示已登录false表示未登录已跳转到登录页面
*/
public static boolean requireLoginWithToast(Context context) {
return requireLoginWithToast(context, null);
}
}

View File

@ -27,6 +27,20 @@ public class CategoryFilterManager {
/**
* 异步筛选房间列表
* TODO: 接入后端接口 - 获取房间分类列表
* 接口路径: GET /api/rooms/categories
* 请求参数:
* 返回数据格式: ApiResponse<List<Category>>
* Category对象应包含: id, name, iconUrl, roomCount等字段
* 用于显示分类标签页分类数据应从后端获取而不是硬编码
* TODO: 接入后端接口 - 按分类获取房间列表
* 接口路径: GET /api/rooms?category={categoryId}
* 请求参数:
* - categoryId: 分类ID路径参数或查询参数
* - page (可选): 页码
* - pageSize (可选): 每页数量
* 返回数据格式: ApiResponse<List<Room>>
* 筛选逻辑应迁移到后端前端只负责展示结果
*/
public void filterRoomsAsync(List<Room> allRooms, String category, FilterCallback callback) {
if (executorService == null || executorService.isShutdown()) {

View File

@ -188,6 +188,11 @@ public class ConversationActivity extends AppCompatActivity {
}
private void sendMessage() {
// 检查登录状态发送私信需要登录
if (!AuthHelper.requireLoginWithToast(this, "发送私信需要登录")) {
return;
}
// TODO: 接入后端接口 - 发送私信消息
// 接口路径: POST /api/conversations/{conversationId}/messages
// 请求参数:

View File

@ -70,6 +70,13 @@ public class EditProfileActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 检查登录状态编辑资料需要登录
if (!AuthHelper.requireLogin(this, "编辑资料需要登录")) {
finish();
return;
}
binding = ActivityEditProfileBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
@ -136,6 +143,24 @@ public class EditProfileActivity extends AppCompatActivity {
});
binding.saveButton.setOnClickListener(v -> {
// TODO: 接入后端接口 - 上传头像
// 接口路径: POST /api/users/{userId}/avatar
// 请求参数:
// - userId: 用户ID从token中获取
// - avatar: 头像文件multipart/form-data
// 返回数据格式: ApiResponse<{avatarUrl: string}>
// 上传成功后保存avatarUrl到本地并更新界面显示
// TODO: 接入后端接口 - 更新用户资料
// 接口路径: PUT /api/users/{userId}/profile
// 请求参数:
// - userId: 用户ID从token中获取
// - name: 昵称
// - bio: 个人签名
// - birthday: 生日格式yyyy-MM-dd
// - gender: 性别//保密
// - location: 所在地格式省份-城市
// 返回数据格式: ApiResponse<UserProfile>
// 更新成功后同步更新本地缓存和界面显示
String name = binding.inputName.getText() != null ? binding.inputName.getText().toString().trim() : "";
String bio = binding.inputBio.getText() != null ? binding.inputBio.getText().toString().trim() : "";
String birthday = binding.inputBirthday.getText() != null ? binding.inputBirthday.getText().toString().trim() : "";
@ -197,6 +222,15 @@ public class EditProfileActivity extends AppCompatActivity {
}
private Uri persistAvatarToInternalStorage(Uri sourceUri) {
// TODO: 接入后端接口 - 上传头像到服务器
// 接口路径: POST /api/upload/image
// 请求参数:
// - file: 图片文件multipart/form-data
// - model: 模块类型"user"
// - pid: 分类ID如7表示前台用户
// 返回数据格式: ApiResponse<{url: string}>
// 上传成功后保存返回的URL到本地并更新界面显示
// 注意这里目前只是保存到本地实际应该先上传到服务器然后保存服务器返回的URL
if (sourceUri == null) return null;
InputStream in = null;
OutputStream out = null;

View File

@ -48,6 +48,16 @@ public class FriendsAdapter extends ListAdapter<FriendItem, FriendsAdapter.VH> {
}
void bind(FriendItem item) {
// TODO: 接入后端接口 - 从后端获取好友头像URL
// 接口路径: GET /api/user/profile/{friendId}
// 请求参数: friendId (路径参数从FriendItem对象中获取)
// 返回数据格式: ApiResponse<{avatarUrl: string}>
// 使用Glide加载头像如果没有则使用默认占位图
// TODO: 接入后端接口 - 获取好友在线状态
// 接口路径: GET /api/user/status/{friendId}
// 请求参数: friendId (路径参数)
// 返回数据格式: ApiResponse<{isOnline: boolean, lastActiveTime: timestamp}>
// 或者FriendItem对象应包含isOnline字段直接从item.isOnline()获取
binding.name.setText(item != null && item.getName() != null ? item.getName() : "");
binding.subtitle.setText(item != null && item.getSubtitle() != null ? item.getSubtitle() : "");

View File

@ -44,6 +44,18 @@ public class LocalNotificationManager {
/**
* 发送通知
* TODO: 接入后端接口 - 同步通知到后端
* 接口路径: POST /api/notifications/sync
* 请求参数:
* - userId: 用户ID从token中获取
* - notificationId: 通知ID本地生成的唯一ID
* - type: 通知类型
* - title: 通知标题
* - content: 通知内容
* - timestamp: 通知时间戳
* 返回数据格式: ApiResponse<{success: boolean, serverNotificationId: string}>
* 用于将本地通知同步到后端确保多设备间通知状态一致
* 注意本地通知仍应正常发送同步失败不应影响本地通知显示
*/
public static void sendNotification(Context context, String title, String content, NotificationItem.Type type) {
if (context == null || title == null || content == null) return;

View File

@ -0,0 +1,236 @@
package com.example.livestreaming;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.View;
import android.widget.ProgressBar;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import com.example.livestreaming.databinding.ActivityLoginBinding;
import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.AuthStore;
import com.example.livestreaming.net.LoginRequest;
import com.example.livestreaming.net.LoginResponse;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class LoginActivity extends AppCompatActivity {
private ActivityLoginBinding binding;
private boolean isLoggingIn = false;
public static void start(Context context) {
Intent intent = new Intent(context, LoginActivity.class);
// 不使用CLEAR_TOP和NEW_TASK保留Activity栈允许用户返回上一页
context.startActivity(intent);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityLoginBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setupUI();
}
private void setupUI() {
// 返回按钮点击事件
binding.backButton.setOnClickListener(v -> finish());
// 登录按钮点击事件
binding.loginButton.setOnClickListener(v -> performLogin());
// 注册链接点击事件
binding.registerLinkText.setOnClickListener(v -> {
RegisterActivity.start(LoginActivity.this);
});
// 忘记密码点击事件
binding.forgotPasswordText.setOnClickListener(v -> {
Toast.makeText(this, "忘记密码功能待开发", Toast.LENGTH_SHORT).show();
});
// 回车键登录
binding.passwordInput.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_DONE) {
performLogin();
return true;
}
return false;
});
}
private void performLogin() {
// 防止重复提交
if (isLoggingIn) return;
String account = binding.accountInput.getText() != null ?
binding.accountInput.getText().toString().trim() : "";
String password = binding.passwordInput.getText() != null ?
binding.passwordInput.getText().toString().trim() : "";
// 验证输入
if (TextUtils.isEmpty(account)) {
binding.accountLayout.setError("请输入账号");
binding.accountInput.requestFocus();
return;
} else {
binding.accountLayout.setError(null);
}
if (TextUtils.isEmpty(password)) {
binding.passwordLayout.setError("请输入密码");
binding.passwordInput.requestFocus();
return;
} else {
binding.passwordLayout.setError(null);
}
// 显示加载状态
isLoggingIn = true;
binding.loginButton.setEnabled(false);
binding.loadingProgress.setVisibility(View.VISIBLE);
// TODO: 接入后端接口 - 用户登录
// 接口路径: POST /api/front/loginApiService中已定义
// 请求参数: LoginRequest {account: string, password: string}
// 返回数据格式: ApiResponse<LoginResponse>
// LoginResponse对象应包含: token, userId, nickname, avatarUrl等字段
// 登录成功后保存token到AuthStore并更新用户信息到本地SharedPreferences
ApiClient.getService(getApplicationContext()).login(new LoginRequest(account, password))
.enqueue(new Callback<ApiResponse<LoginResponse>>() {
@Override
public void onResponse(Call<ApiResponse<LoginResponse>> call, Response<ApiResponse<LoginResponse>> response) {
isLoggingIn = false;
binding.loginButton.setEnabled(true);
binding.loadingProgress.setVisibility(View.GONE);
ApiResponse<LoginResponse> body = response.body();
LoginResponse loginData = body != null ? body.getData() : null;
// 如果响应不成功或数据无效检查是否是后端未接入的情况
if (!response.isSuccessful() || body == null || !body.isOk() || loginData == null) {
// 如果是404500等错误可能是后端未接入使用演示模式
if (!response.isSuccessful() && (response.code() == 404 || response.code() == 500 || response.code() == 502 || response.code() == 503)) {
// 后端服务未启动或未接入使用演示模式
handleDemoModeLogin(account);
return;
}
String errorMsg = "登录失败";
if (body != null && !TextUtils.isEmpty(body.getMessage())) {
errorMsg = body.getMessage();
} else if (!response.isSuccessful()) {
errorMsg = "网络错误:" + response.code();
}
Toast.makeText(LoginActivity.this, errorMsg, Toast.LENGTH_SHORT).show();
return;
}
// 保存token
String token = loginData.getToken();
if (!TextUtils.isEmpty(token)) {
AuthStore.setToken(getApplicationContext(), token);
}
// 保存用户信息到本地如果LoginResponse包含用户信息
// 注意这里假设LoginResponse可能包含userId和nickname如果后端返回了这些字段需要在这里保存
SharedPreferences prefs = getSharedPreferences("profile_prefs", MODE_PRIVATE);
// 如果后端返回了nickname可以在这里保存
// prefs.edit().putString("profile_name", loginData.getNickname()).apply();
// 登录成功返回上一页如果是从其他页面跳转过来的
// 如果是直接打开登录页面则跳转到主页面
Toast.makeText(LoginActivity.this, "登录成功", Toast.LENGTH_SHORT).show();
// 检查是否有上一个Activity
if (isTaskRoot()) {
// 如果没有上一个Activity跳转到主页面
Intent intent = new Intent(LoginActivity.this, MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
finish();
} else {
// 有上一个Activity直接返回
finish();
}
}
@Override
public void onFailure(Call<ApiResponse<LoginResponse>> call, Throwable t) {
isLoggingIn = false;
binding.loginButton.setEnabled(true);
binding.loadingProgress.setVisibility(View.GONE);
// 检测是否是网络连接错误后端未启动
boolean isNetworkError = false;
String errorMsg = "网络错误";
if (t != null) {
String msg = t.getMessage();
if (msg != null) {
if (msg.contains("Unable to resolve host") ||
msg.contains("timeout") ||
msg.contains("Connection refused") ||
msg.contains("Failed to connect")) {
isNetworkError = true;
errorMsg = "无法连接到服务器,已切换到演示模式";
} else {
errorMsg = "网络错误:" + msg;
}
}
}
// 如果是网络连接错误后端未接入使用演示模式登录
if (isNetworkError) {
handleDemoModeLogin(account);
} else {
Toast.makeText(LoginActivity.this, errorMsg, Toast.LENGTH_LONG).show();
}
}
});
}
/**
* 处理演示模式登录
*/
private void handleDemoModeLogin(String account) {
// 演示模式允许任意账号密码登录仅用于开发测试
Toast.makeText(this, "演示模式:后端未接入,使用演示账号登录", Toast.LENGTH_LONG).show();
// 生成一个演示token基于账号
String demoToken = "demo_token_" + account.hashCode();
AuthStore.setToken(getApplicationContext(), demoToken);
// 保存用户信息到本地
SharedPreferences prefs = getSharedPreferences("profile_prefs", MODE_PRIVATE);
prefs.edit()
.putString("profile_name", account.length() > 0 ? account : "演示用户")
.apply();
// 登录成功返回上一页如果是从其他页面跳转过来的
// 如果是直接打开登录页面则跳转到主页面
Toast.makeText(this, "演示模式登录成功", Toast.LENGTH_SHORT).show();
// 检查是否有上一个Activity
if (isTaskRoot()) {
// 如果没有上一个Activity跳转到主页面
Intent intent = new Intent(this, MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
finish();
} else {
// 有上一个Activity直接返回
finish();
}
}
}

View File

@ -90,6 +90,20 @@ public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 用户打开APP时不需要强制登录可以直接使用APP
// 只有在使用需要登录的功能时如加好友发送弹幕等才检查登录状态
// TODO: 接入后端接口 - 用户登录
// 接口路径: POST /api/front/loginApiService中已定义
// 请求参数: LoginRequest {account: string, password: string}
// 返回数据格式: ApiResponse<LoginResponse>
// LoginResponse对象应包含: token, userId, nickname, avatarUrl等字段
// 登录成功后保存token到AuthStore并更新用户信息
// TODO: 接入后端接口 - 用户注册
// 接口路径: POST /api/front/registerApiService中已定义
// 请求参数: RegisterRequest {phone: string, password: string, verificationCode: string, nickname: string}
// 返回数据格式: ApiResponse<LoginResponse>
// 注册成功后自动登录并保存token
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
@ -173,49 +187,81 @@ public class MainActivity extends AppCompatActivity {
private void handleDrawerAction(DrawerCardItem item) {
int action = item.getAction();
if (action == DrawerCardItem.ACTION_PROFILE) {
// 检查登录状态个人主页需要登录
if (!AuthHelper.requireLogin(this, "查看个人主页需要登录")) {
return;
}
ProfileActivity.start(this);
finish();
return;
}
if (action == DrawerCardItem.ACTION_MESSAGES) {
// MessagesActivity内部已检查登录这里直接跳转
MessagesActivity.start(this);
finish();
return;
}
if (action == DrawerCardItem.ACTION_MY_FRIENDS) {
// 检查登录状态我的好友需要登录
if (!AuthHelper.requireLogin(this, "查看好友列表需要登录")) {
return;
}
startActivity(new Intent(this, MyFriendsActivity.class));
return;
}
if (action == DrawerCardItem.ACTION_FISH_POND) {
// 检查登录状态缘池功能需要登录
if (!AuthHelper.requireLogin(this, "缘池功能需要登录")) {
return;
}
startActivity(new Intent(this, FishPondActivity.class));
finish();
return;
}
if (action == DrawerCardItem.ACTION_FOLLOWING) {
// 检查登录状态我的关注需要登录
if (!AuthHelper.requireLogin(this, "查看关注列表需要登录")) {
return;
}
FollowingListActivity.start(this);
return;
}
if (action == DrawerCardItem.ACTION_FANS) {
// 检查登录状态我的粉丝需要登录
if (!AuthHelper.requireLogin(this, "查看粉丝列表需要登录")) {
return;
}
FansListActivity.start(this);
return;
}
if (action == DrawerCardItem.ACTION_LIKES) {
// 检查登录状态获赞列表需要登录
if (!AuthHelper.requireLogin(this, "查看获赞列表需要登录")) {
return;
}
LikesListActivity.start(this);
return;
}
if (action == DrawerCardItem.ACTION_HISTORY) {
// 检查登录状态观看历史需要登录
if (!AuthHelper.requireLogin(this, "查看观看历史需要登录")) {
return;
}
WatchHistoryActivity.start(this);
return;
}
if (action == DrawerCardItem.ACTION_SEARCH) {
// 搜索功能不需要登录游客也可以搜索
SearchActivity.start(this);
return;
}
if (action == DrawerCardItem.ACTION_SETTINGS) {
// SettingsPageActivity内部已检查登录这里直接跳转
SettingsPageActivity.start(this, "");
return;
}
if (action == DrawerCardItem.ACTION_HELP) {
// 帮助页面不需要登录
SettingsPageActivity.start(this, SettingsPageActivity.PAGE_HELP);
return;
}
@ -274,6 +320,10 @@ public class MainActivity extends AppCompatActivity {
});
binding.avatarButton.setOnClickListener(v -> {
// 检查登录状态个人主页需要登录
if (!AuthHelper.requireLogin(this, "查看个人主页需要登录")) {
return;
}
ProfileActivity.start(this);
finish();
});
@ -408,6 +458,10 @@ public class MainActivity extends AppCompatActivity {
return true;
}
if (id == R.id.nav_profile) {
// 检查登录状态个人主页需要登录
if (!AuthHelper.requireLogin(this, "查看个人主页需要登录")) {
return false;
}
ProfileActivity.start(this);
finish();
return true;
@ -654,6 +708,32 @@ public class MainActivity extends AppCompatActivity {
} else {
Toast.makeText(this, "需要麦克风权限才能使用语音搜索", Toast.LENGTH_SHORT).show();
}
} else if (requestCode == REQUEST_LOCATION_PERMISSION) {
// 处理位置权限请求结果
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// 权限已授予刷新附近页面显示数据
showNearbyTab();
} else {
// 权限被拒绝
// 检查是否是因为用户选择了"不再询问"
// 如果 shouldShowRequestPermissionRationale 返回 false说明用户可能选择了"不再询问"
boolean shouldShowRationale = ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.ACCESS_FINE_LOCATION);
if (!shouldShowRationale) {
// 用户可能选择了"不再询问"提示去设置中开启
new AlertDialog.Builder(this)
.setTitle("需要位置权限")
.setMessage("您已拒绝位置权限,如需使用附近功能,请在设置中手动开启位置权限。")
.setPositiveButton("去设置", (dialog, which) -> {
Intent intent = new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(android.net.Uri.parse("package:" + getPackageName()));
startActivity(intent);
})
.setNegativeButton("取消", null)
.show();
}
// 不切换页面让用户停留在附近页面
// 界面已经显示了需要权限的提示用户可以再次点击"授权"按钮
}
}
}
@ -816,6 +896,12 @@ public class MainActivity extends AppCompatActivity {
dialog.setOnShowListener(d -> {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {
// 检查登录状态创建直播间需要登录
if (!AuthHelper.requireLogin(this, "创建直播间需要登录")) {
dialog.dismiss();
return;
}
String title = dialogBinding.titleEdit.getText() != null ? dialogBinding.titleEdit.getText().toString().trim() : "";
String type = (typeSpinner != null && typeSpinner.getText() != null) ? typeSpinner.getText().toString().trim() : "";
@ -1398,6 +1484,12 @@ public class MainActivity extends AppCompatActivity {
/**
* 初始化顶部标签页数据
* TODO: 接入后端接口 - 获取顶部标签页配置关注/发现/附近
* 接口路径: GET /api/home/tabs
* 请求参数: 从token中获取userId可选
* 返回数据格式: ApiResponse<List<TabConfig>>
* TabConfig对象应包含: id, name, iconUrl, badgeCount未读数等等字段
* 用于动态配置顶部标签页支持个性化显示
*/
private void initializeTopTabData() {
// 初始化关注页面数据已关注主播的直播- 使用演示数据
@ -1549,16 +1641,7 @@ public class MainActivity extends AppCompatActivity {
* 显示附近页面
*/
private void showNearbyTab() {
// 检查位置权限
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED
&& ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION)
!= PackageManager.PERMISSION_GRANTED) {
// 请求位置权限
requestLocationPermission();
return;
}
// 先切换界面布局无论是否有权限
// 隐藏分类标签附近页面不需要分类筛选
if (binding.categoryTabs != null) {
binding.categoryTabs.setVisibility(View.GONE);
@ -1577,10 +1660,37 @@ public class MainActivity extends AppCompatActivity {
if (binding.roomsRecyclerView != null) {
binding.roomsRecyclerView.setLayoutManager(nearbyLayoutManager);
binding.roomsRecyclerView.setAdapter(nearbyUsersAdapter);
nearbyUsersAdapter.submitList(new ArrayList<>(nearbyUsers));
binding.roomsRecyclerView.setVisibility(View.VISIBLE);
}
// 检查位置权限
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED
&& ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION)
!= PackageManager.PERMISSION_GRANTED) {
// 显示需要权限的提示
if (binding.emptyStateView != null) {
binding.emptyStateView.setIcon(R.drawable.ic_search_24);
binding.emptyStateView.setTitle("需要位置权限");
binding.emptyStateView.setMessage("附近功能需要访问位置信息,以便为您推荐附近的用户和直播");
binding.emptyStateView.setActionText("授权");
binding.emptyStateView.setOnActionClickListener(v -> requestLocationPermission());
binding.emptyStateView.setVisibility(View.VISIBLE);
}
// 清空列表因为还没有权限获取数据
if (nearbyUsersAdapter != null) {
nearbyUsersAdapter.submitList(new ArrayList<>());
}
// 请求位置权限但不阻塞界面切换
requestLocationPermission();
return;
}
// 有权限显示附近用户列表
if (nearbyUsersAdapter != null) {
nearbyUsersAdapter.submitList(new ArrayList<>(nearbyUsers));
}
// 更新空状态
if (nearbyUsers.isEmpty()) {
if (binding.emptyStateView != null) {
@ -1759,11 +1869,18 @@ public class MainActivity extends AppCompatActivity {
* 请求位置权限
*/
private void requestLocationPermission() {
if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.ACCESS_FINE_LOCATION)) {
// 检查是否应该显示权限说明
// shouldShowRequestPermissionRationale 返回 true 表示用户之前拒绝过但还可以再次请求
// 返回 false 可能是第一次请求也可能是用户选择了"不再询问"
boolean shouldShowRationale = ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.ACCESS_FINE_LOCATION);
if (shouldShowRationale) {
// 用户之前拒绝过但还可以再次请求显示说明后直接请求权限
new AlertDialog.Builder(this)
.setTitle("需要位置权限")
.setMessage("附近功能需要访问位置信息,以便为您推荐附近的用户和直播。请在设置中允许位置权限。")
.setPositiveButton("确定", (dialog, which) -> {
.setMessage("附近功能需要访问位置信息,以便为您推荐附近的用户和直播。")
.setPositiveButton("授权", (dialog, which) -> {
// 直接请求权限
ActivityCompat.requestPermissions(this,
new String[]{
Manifest.permission.ACCESS_FINE_LOCATION,
@ -1771,19 +1888,13 @@ public class MainActivity extends AppCompatActivity {
},
REQUEST_LOCATION_PERMISSION);
})
.setNegativeButton("取消", (dialog, which) -> {
// 用户拒绝权限显示提示
Toast.makeText(this, "需要位置权限才能使用附近功能", Toast.LENGTH_SHORT).show();
// 切换回发现页面
if (binding.topTabs != null) {
TabLayout.Tab discoverTab = binding.topTabs.getTabAt(1);
if (discoverTab != null) {
discoverTab.select();
}
}
})
.setNegativeButton("取消", null)
.setCancelable(true)
.show();
} else {
// 第一次请求权限或者用户选择了"不再询问"
// 直接请求权限如果用户选择了"不再询问"系统会静默失败
// 我们会在权限回调中处理这种情况
ActivityCompat.requestPermissions(this,
new String[]{
Manifest.permission.ACCESS_FINE_LOCATION,

View File

@ -67,6 +67,15 @@ public class MyFriendsActivity extends AppCompatActivity {
}
private void applyFilter(String query) {
// TODO: 接入后端接口 - 搜索好友
// 接口路径: GET /api/friends/search
// 请求参数:
// - userId: 当前用户ID从token中获取
// - keyword: 搜索关键词
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<FriendItem>>
// 搜索范围包括好友昵称备注共同关注等
if (query == null || query.trim().isEmpty()) {
adapter.submitList(new ArrayList<>(all));
updateEmptyState(all);

View File

@ -9,6 +9,7 @@ import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.example.livestreaming.databinding.ItemNearbyUserBinding;
public class NearbyUsersAdapter extends ListAdapter<NearbyUser, NearbyUsersAdapter.VH> {
@ -48,29 +49,51 @@ public class NearbyUsersAdapter extends ListAdapter<NearbyUser, NearbyUsersAdapt
}
void bind(NearbyUser user) {
binding.userName.setText(user != null && user.getName() != null ? user.getName() : "");
if (user == null) return;
// 格式化距离文本
String distanceText = user != null && user.getDistanceText() != null ? user.getDistanceText() : "";
if (!distanceText.isEmpty() && !distanceText.startsWith("距离")) {
binding.distanceText.setText("距离 " + distanceText);
} else {
binding.distanceText.setText(distanceText);
// 设置用户名
binding.userName.setText(user.getName() != null ? user.getName() : "");
// 格式化距离文本移除"距离"前缀因为布局中已经有位置图标
String distanceText = user.getDistanceText() != null ? user.getDistanceText() : "";
if (distanceText.startsWith("距离")) {
// 如果已经有"距离"前缀移除它
distanceText = distanceText.replaceFirst("距离\\s*", "");
}
binding.distanceText.setText(distanceText);
// 设置头像使用简单的占位符不再使用动态比例
// 头像已经在布局中固定为圆形不需要动态调整
// 使用Glide加载头像
// TODO: 接入后端接口 - 从后端获取附近用户头像URL
// 接口路径: GET /api/user/profile/{userId}
// 请求参数: userId (路径参数从NearbyUser对象中获取)
// 返回数据格式: ApiResponse<{avatarUrl: string}>
// 目前使用默认占位图
Glide.with(binding.avatarImage.getContext())
.load((String) null) // 暂时为null等待后端接口
.circleCrop()
.placeholder(com.example.livestreaming.R.drawable.ic_account_circle_24)
.error(com.example.livestreaming.R.drawable.ic_account_circle_24)
.into(binding.avatarImage);
// 显示/隐藏直播状态徽章
if (user.isLive()) {
binding.liveBadge.setVisibility(View.VISIBLE);
} else {
binding.liveBadge.setVisibility(View.GONE);
}
// 添加按钮点击事件
binding.addButton.setOnClickListener(v -> {
if (user == null) return;
if (onUserClickListener != null) onUserClickListener.onUserClick(user);
if (onUserClickListener != null) {
onUserClickListener.onUserClick(user);
}
});
// 整个item点击也可以触发添加
binding.getRoot().setOnClickListener(v -> {
if (user == null) return;
if (onUserClickListener != null) onUserClickListener.onUserClick(user);
if (onUserClickListener != null) {
onUserClickListener.onUserClick(user);
}
});
}
}

View File

@ -73,6 +73,14 @@ public class NotificationSettingsActivity extends AppCompatActivity {
}
private void refreshItems() {
// TODO: 接入后端接口 - 获取用户通知设置
// 接口路径: GET /api/users/{userId}/notification/settings
// 请求参数:
// - userId: 用户ID从token中获取
// 返回数据格式: ApiResponse<NotificationSettings>
// NotificationSettings对象应包含: systemEnabled, followEnabled, commentEnabled,
// messageEnabled, liveEnabled, dndEnabled, dndStartHour, dndEndHour等字段
// 首次加载时从接口获取后续可从本地缓存读取
List<MoreItem> items = new ArrayList<>();
// 系统通知开关
@ -123,6 +131,20 @@ public class NotificationSettingsActivity extends AppCompatActivity {
.setTitle("免打扰设置")
.setMessage(message)
.setPositiveButton("开启", (dialog, which) -> {
// TODO: 接入后端接口 - 更新通知设置
// 接口路径: PUT /api/users/{userId}/notification/settings
// 请求参数:
// - userId: 用户ID从token中获取
// - systemEnabled (可选): 系统通知开关
// - followEnabled (可选): 关注提醒开关
// - commentEnabled (可选): 评论提醒开关
// - messageEnabled (可选): 私信提醒开关
// - liveEnabled (可选): 开播提醒开关
// - dndEnabled (可选): 免打扰开关
// - dndStartHour (可选): 免打扰开始时间小时0-23
// - dndEndHour (可选): 免打扰结束时间小时0-23
// 返回数据格式: ApiResponse<{success: boolean}>
// 更新成功后同步更新本地缓存和界面显示
prefs.edit().putBoolean(KEY_DND_ENABLED, true).apply();
refreshItems();
Toast.makeText(this, "免打扰已开启", Toast.LENGTH_SHORT).show();

View File

@ -32,6 +32,13 @@ public class NotificationsActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 检查登录状态通知列表需要登录
if (!AuthHelper.requireLogin(this, "查看通知需要登录")) {
finish();
return;
}
binding = ActivityNotificationsBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());

View File

@ -110,6 +110,11 @@ public class NotificationsAdapter extends ListAdapter<NotificationItem, Notifica
}
private void loadAvatar(NotificationItem item) {
// TODO: 接入后端接口 - 从后端获取通知发送者的头像URL
// 接口路径: GET /api/user/profile/{senderId}
// 请求参数: senderId (路径参数从NotificationItem中获取)
// 返回数据格式: ApiResponse<{avatarUrl: string}>
// 如果NotificationItem包含senderId则调用此接口获取头像否则使用默认图标
if (binding.avatar == null) return;
String avatarUrl = item.getAvatarUrl();

View File

@ -46,6 +46,15 @@ public class PlayerActivity extends AppCompatActivity {
@Override
protected void onStart() {
super.onStart();
// TODO: 接入后端接口 - 记录播放开始
// 接口路径: POST /api/play/start
// 请求参数:
// - userId: 当前用户ID从token中获取可选
// - roomId: 房间ID从Intent中获取
// - playUrl: 播放地址
// - timestamp: 开始播放时间戳
// 返回数据格式: ApiResponse<{success: boolean}>
// 用于统计播放数据和用户行为分析
String url = getIntent().getStringExtra(EXTRA_PLAY_URL);
if (url == null || url.trim().isEmpty()) return;
@ -115,6 +124,15 @@ public class PlayerActivity extends AppCompatActivity {
@Override
protected void onStop() {
super.onStop();
// TODO: 接入后端接口 - 记录播放结束
// 接口路径: POST /api/play/end
// 请求参数:
// - userId: 当前用户ID从token中获取可选
// - roomId: 房间ID从Intent中获取
// - playDuration: 播放时长
// - timestamp: 结束播放时间戳
// 返回数据格式: ApiResponse<{success: boolean}>
// 用于统计播放时长和用户观看行为
releaseExoPlayer();
releaseIjkPlayer();
}

View File

@ -246,7 +246,13 @@ public class ProfileActivity extends AppCompatActivity {
private void setupNavigationClicks() {
binding.topActionSearch.setOnClickListener(v -> TabPlaceholderActivity.start(this, "定位/发现"));
binding.topActionClock.setOnClickListener(v -> WatchHistoryActivity.start(this));
binding.topActionClock.setOnClickListener(v -> {
// 检查登录状态观看历史需要登录
if (!AuthHelper.requireLogin(this, "查看观看历史需要登录")) {
return;
}
WatchHistoryActivity.start(this);
});
binding.topActionMore.setOnClickListener(v -> TabPlaceholderActivity.start(this, "更多"));
binding.copyIdBtn.setOnClickListener(v -> {
@ -267,19 +273,53 @@ public class ProfileActivity extends AppCompatActivity {
// - userId: 用户ID路径参数
// 返回数据格式: ApiResponse<{followingCount: number, fansCount: number, likesCount: number}>
// 在ProfileActivity加载时调用更新关注粉丝获赞数量显示
binding.following.setOnClickListener(v -> FollowingListActivity.start(this));
binding.followers.setOnClickListener(v -> FansListActivity.start(this));
binding.likes.setOnClickListener(v -> LikesListActivity.start(this));
binding.following.setOnClickListener(v -> {
// 检查登录状态查看关注列表需要登录
if (!AuthHelper.requireLogin(this, "查看关注列表需要登录")) {
return;
}
FollowingListActivity.start(this);
});
binding.followers.setOnClickListener(v -> {
// 检查登录状态查看粉丝列表需要登录
if (!AuthHelper.requireLogin(this, "查看粉丝列表需要登录")) {
return;
}
FansListActivity.start(this);
});
binding.likes.setOnClickListener(v -> {
// 检查登录状态查看获赞列表需要登录
if (!AuthHelper.requireLogin(this, "查看获赞列表需要登录")) {
return;
}
LikesListActivity.start(this);
});
binding.action1.setOnClickListener(v -> TabPlaceholderActivity.start(this, "公园勋章"));
binding.action2.setOnClickListener(v -> WatchHistoryActivity.start(this));
binding.action2.setOnClickListener(v -> {
// 检查登录状态观看历史需要登录
if (!AuthHelper.requireLogin(this, "查看观看历史需要登录")) {
return;
}
WatchHistoryActivity.start(this);
});
binding.action3.setOnClickListener(v -> startActivity(new Intent(this, MyFriendsActivity.class)));
binding.editProfile.setOnClickListener(v -> {
// 检查登录状态编辑资料需要登录
if (!AuthHelper.requireLogin(this, "编辑资料需要登录")) {
return;
}
Intent intent = new Intent(this, EditProfileActivity.class);
editProfileLauncher.launch(intent);
});
binding.shareHome.setOnClickListener(v -> showShareProfileDialog());
binding.shareHome.setOnClickListener(v -> {
// 检查登录状态分享个人主页需要登录
if (!AuthHelper.requireLogin(this, "分享个人主页需要登录")) {
return;
}
showShareProfileDialog();
});
binding.addFriendBtn.setOnClickListener(v -> TabPlaceholderActivity.start(this, "加好友"));
}
@ -304,16 +344,60 @@ public class ProfileActivity extends AppCompatActivity {
}
});
binding.worksPublishBtn.setOnClickListener(v -> Toast.makeText(this, "发布功能待接入", Toast.LENGTH_SHORT).show());
// TODO: 接入后端接口 - 发布作品
// 接口路径: POST /api/works
// 请求参数:
// - userId: 用户ID从token中获取
// - title: 作品标题
// - description: 作品描述可选
// - coverUrl: 封面图片URL必填需要先上传图片
// - videoUrl (可选): 视频URL如果是视频作品
// - images (可选): 图片URL列表如果是图片作品
// 返回数据格式: ApiResponse<WorkItem>
// WorkItem对象应包含: id, title, coverUrl, likeCount, viewCount, publishTime等字段
// 发布成功后刷新作品列表显示
binding.worksPublishBtn.setOnClickListener(v -> {
// 检查登录状态发布作品需要登录
if (!AuthHelper.requireLogin(this, "发布作品需要登录")) {
return;
}
Toast.makeText(this, "发布功能待接入", Toast.LENGTH_SHORT).show();
});
binding.likedGoBrowseBtn.setOnClickListener(v -> startActivity(new Intent(this, MainActivity.class)));
binding.favGoBrowseBtn.setOnClickListener(v -> startActivity(new Intent(this, MainActivity.class)));
binding.profileEditFromTab.setOnClickListener(v -> {
// 检查登录状态编辑资料需要登录
if (!AuthHelper.requireLogin(this, "编辑资料需要登录")) {
return;
}
Intent intent = new Intent(this, EditProfileActivity.class);
editProfileLauncher.launch(intent);
});
}
private void showTab(int index) {
// TODO: 接入后端接口 - 获取用户作品列表
// 接口路径: GET /api/users/{userId}/works
// 请求参数:
// - userId: 用户ID从token中获取
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<WorkItem>>
// WorkItem对象应包含: id, title, coverUrl, likeCount, viewCount, publishTime等字段
// TODO: 接入后端接口 - 获取用户收藏列表
// 接口路径: GET /api/users/{userId}/favorites
// 请求参数:
// - userId: 用户ID从token中获取
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<WorkItem>>
// TODO: 接入后端接口 - 获取用户赞过的作品列表
// 接口路径: GET /api/users/{userId}/liked
// 请求参数:
// - userId: 用户ID从token中获取
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<WorkItem>>
// 标签页顺序0-作品, 1-收藏, 2-赞过
binding.tabWorks.setVisibility(index == 0 ? View.VISIBLE : View.GONE);
binding.tabFavorites.setVisibility(index == 1 ? View.VISIBLE : View.GONE);

View File

@ -0,0 +1,350 @@
package com.example.livestreaming;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.os.CountDownTimer;
import android.text.TextUtils;
import android.view.View;
import android.widget.ProgressBar;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import com.example.livestreaming.databinding.ActivityRegisterBinding;
import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.AuthStore;
import com.example.livestreaming.net.LoginResponse;
import com.example.livestreaming.net.RegisterRequest;
import java.util.regex.Pattern;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class RegisterActivity extends AppCompatActivity {
private ActivityRegisterBinding binding;
private boolean isRegistering = false;
private CountDownTimer countDownTimer;
private boolean isCodeSending = false;
public static void start(Context context) {
Intent intent = new Intent(context, RegisterActivity.class);
context.startActivity(intent);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityRegisterBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setupUI();
}
@Override
protected void onDestroy() {
super.onDestroy();
if (countDownTimer != null) {
countDownTimer.cancel();
}
}
private void setupUI() {
// 返回按钮
binding.backButton.setOnClickListener(v -> finish());
// 发送验证码按钮
binding.sendCodeButton.setOnClickListener(v -> sendVerificationCode());
// 注册按钮
binding.registerButton.setOnClickListener(v -> performRegister());
// 登录链接
binding.loginLinkText.setOnClickListener(v -> {
LoginActivity.start(RegisterActivity.this);
finish();
});
// 回车键注册
binding.nicknameInput.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_DONE) {
performRegister();
return true;
}
return false;
});
}
private void sendVerificationCode() {
if (isCodeSending) return;
String phone = binding.phoneInput.getText() != null ?
binding.phoneInput.getText().toString().trim() : "";
// 验证手机号
if (TextUtils.isEmpty(phone)) {
binding.phoneLayout.setError("请输入手机号");
binding.phoneInput.requestFocus();
return;
}
// 简单的手机号格式验证11位数字
Pattern phonePattern = Pattern.compile("^1[3-9]\\d{9}$");
if (!phonePattern.matcher(phone).matches()) {
binding.phoneLayout.setError("请输入正确的手机号");
binding.phoneInput.requestFocus();
return;
}
binding.phoneLayout.setError(null);
// TODO: 接入后端接口 - 发送手机验证码
// 接口路径: POST /api/sms/send
// 请求参数:
// - phone: 手机号
// - type: 验证码类型register
// 返回数据格式: ApiResponse<{success: boolean, message: string}>
// 发送成功后开始倒计时防止重复发送
isCodeSending = true;
binding.sendCodeButton.setEnabled(false);
// 模拟发送验证码实际应该调用后端接口
Toast.makeText(this, "验证码已发送", Toast.LENGTH_SHORT).show();
// 开始倒计时60秒
startCountDown(60);
// 实际代码应该是
// ApiClient.getService(getApplicationContext()).sendVerificationCode(phone, "register")
// .enqueue(new Callback<ApiResponse<Object>>() {
// @Override
// public void onResponse(Call<ApiResponse<Object>> call, Response<ApiResponse<Object>> response) {
// isCodeSending = false;
// if (response.isSuccessful() && response.body() != null && response.body().isOk()) {
// Toast.makeText(RegisterActivity.this, "验证码已发送", Toast.LENGTH_SHORT).show();
// startCountDown(60);
// } else {
// binding.sendCodeButton.setEnabled(true);
// String msg = response.body() != null ? response.body().getMessage() : "发送失败";
// Toast.makeText(RegisterActivity.this, msg, Toast.LENGTH_SHORT).show();
// }
// }
//
// @Override
// public void onFailure(Call<ApiResponse<Object>> call, Throwable t) {
// isCodeSending = false;
// binding.sendCodeButton.setEnabled(true);
// Toast.makeText(RegisterActivity.this, "网络错误", Toast.LENGTH_SHORT).show();
// }
// });
}
private void startCountDown(int seconds) {
if (countDownTimer != null) {
countDownTimer.cancel();
}
countDownTimer = new CountDownTimer(seconds * 1000L, 1000L) {
@Override
public void onTick(long millisUntilFinished) {
int remaining = (int) (millisUntilFinished / 1000);
binding.sendCodeButton.setText(remaining + "秒后重发");
}
@Override
public void onFinish() {
isCodeSending = false;
binding.sendCodeButton.setEnabled(true);
binding.sendCodeButton.setText("发送验证码");
}
};
countDownTimer.start();
}
private void performRegister() {
// 防止重复提交
if (isRegistering) return;
String phone = binding.phoneInput.getText() != null ?
binding.phoneInput.getText().toString().trim() : "";
String verificationCode = binding.verificationCodeInput.getText() != null ?
binding.verificationCodeInput.getText().toString().trim() : "";
String password = binding.passwordInput.getText() != null ?
binding.passwordInput.getText().toString().trim() : "";
String nickname = binding.nicknameInput.getText() != null ?
binding.nicknameInput.getText().toString().trim() : "";
// 验证输入
if (TextUtils.isEmpty(phone)) {
binding.phoneLayout.setError("请输入手机号");
binding.phoneInput.requestFocus();
return;
} else {
binding.phoneLayout.setError(null);
}
Pattern phonePattern = Pattern.compile("^1[3-9]\\d{9}$");
if (!phonePattern.matcher(phone).matches()) {
binding.phoneLayout.setError("请输入正确的手机号");
binding.phoneInput.requestFocus();
return;
}
if (TextUtils.isEmpty(verificationCode)) {
binding.verificationCodeLayout.setError("请输入验证码");
binding.verificationCodeInput.requestFocus();
return;
} else {
binding.verificationCodeLayout.setError(null);
}
if (TextUtils.isEmpty(password)) {
binding.passwordLayout.setError("请输入密码");
binding.passwordInput.requestFocus();
return;
} else if (password.length() < 6) {
binding.passwordLayout.setError("密码至少6位");
binding.passwordInput.requestFocus();
return;
} else {
binding.passwordLayout.setError(null);
}
if (TextUtils.isEmpty(nickname)) {
binding.nicknameLayout.setError("请输入昵称");
binding.nicknameInput.requestFocus();
return;
} else {
binding.nicknameLayout.setError(null);
}
// 显示加载状态
isRegistering = true;
binding.registerButton.setEnabled(false);
binding.loadingProgress.setVisibility(View.VISIBLE);
// TODO: 接入后端接口 - 用户注册
// 接口路径: POST /api/front/registerApiService中已定义
// 请求参数: RegisterRequest {phone: string, password: string, verificationCode: string, nickname: string}
// 返回数据格式: ApiResponse<LoginResponse>
// LoginResponse对象应包含: token, userId, nickname, avatarUrl等字段
// 注册成功后自动登录并保存token更新用户信息到本地SharedPreferences
ApiClient.getService(getApplicationContext()).register(
new RegisterRequest(phone, password, verificationCode, nickname))
.enqueue(new Callback<ApiResponse<LoginResponse>>() {
@Override
public void onResponse(Call<ApiResponse<LoginResponse>> call, Response<ApiResponse<LoginResponse>> response) {
isRegistering = false;
binding.registerButton.setEnabled(true);
binding.loadingProgress.setVisibility(View.GONE);
ApiResponse<LoginResponse> body = response.body();
LoginResponse loginData = body != null ? body.getData() : null;
if (!response.isSuccessful() || body == null || !body.isOk() || loginData == null) {
String errorMsg = "注册失败";
if (body != null && !TextUtils.isEmpty(body.getMessage())) {
errorMsg = body.getMessage();
} else if (!response.isSuccessful()) {
errorMsg = "网络错误:" + response.code();
}
Toast.makeText(RegisterActivity.this, errorMsg, Toast.LENGTH_SHORT).show();
return;
}
// 保存token
String token = loginData.getToken();
if (!TextUtils.isEmpty(token)) {
AuthStore.setToken(getApplicationContext(), token);
}
// 保存用户信息到本地
SharedPreferences prefs = getSharedPreferences("profile_prefs", MODE_PRIVATE);
prefs.edit()
.putString("profile_name", nickname)
.apply();
// 注册成功自动登录返回上一页如果是从其他页面跳转过来的
// 如果是直接打开注册页面则跳转到主页面
Toast.makeText(RegisterActivity.this, "注册成功", Toast.LENGTH_SHORT).show();
// 检查是否有上一个Activity
if (isTaskRoot()) {
// 如果没有上一个Activity跳转到主页面
Intent intent = new Intent(RegisterActivity.this, MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
finish();
} else {
// 有上一个Activity直接返回
finish();
}
}
@Override
public void onFailure(Call<ApiResponse<LoginResponse>> call, Throwable t) {
isRegistering = false;
binding.registerButton.setEnabled(true);
binding.loadingProgress.setVisibility(View.GONE);
// 检测是否是网络连接错误后端未启动
boolean isNetworkError = false;
String errorMsg = "网络错误";
if (t != null) {
String msg = t.getMessage();
if (msg != null) {
if (msg.contains("Unable to resolve host") ||
msg.contains("timeout") ||
msg.contains("Connection refused") ||
msg.contains("Failed to connect")) {
isNetworkError = true;
errorMsg = "无法连接到服务器,已切换到演示模式";
} else {
errorMsg = "网络错误:" + msg;
}
}
}
// 如果是网络连接错误后端未接入使用演示模式注册
if (isNetworkError) {
// 演示模式允许任意信息注册仅用于开发测试
Toast.makeText(RegisterActivity.this, "演示模式:后端未接入,使用演示账号注册", Toast.LENGTH_LONG).show();
// 生成一个演示token基于手机号
String demoToken = "demo_token_" + phone.hashCode();
AuthStore.setToken(getApplicationContext(), demoToken);
// 保存用户信息到本地
SharedPreferences prefs = getSharedPreferences("profile_prefs", MODE_PRIVATE);
prefs.edit()
.putString("profile_name", nickname)
.apply();
// 注册成功自动登录返回上一页如果是从其他页面跳转过来的
// 如果是直接打开注册页面则跳转到主页面
Toast.makeText(RegisterActivity.this, "演示模式注册成功", Toast.LENGTH_SHORT).show();
// 检查是否有上一个Activity
if (isTaskRoot()) {
// 如果没有上一个Activity跳转到主页面
Intent intent = new Intent(RegisterActivity.this, MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
finish();
} else {
// 有上一个Activity直接返回
finish();
}
} else {
Toast.makeText(RegisterActivity.this, errorMsg, Toast.LENGTH_LONG).show();
}
}
});
}
}

View File

@ -6,7 +6,6 @@ import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.util.Log;
import android.view.Surface;
import android.view.TextureView;
import android.view.KeyEvent;
@ -27,15 +26,10 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import com.example.livestreaming.databinding.ActivityRoomDetailNewBinding;
import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.ChatMessageResponse;
import com.example.livestreaming.net.Room;
import com.example.livestreaming.net.StreamConfig;
import com.example.livestreaming.ShareUtils;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import tv.danmaku.ijk.media.player.IMediaPlayer;
import tv.danmaku.ijk.media.player.IjkMediaPlayer;
@ -49,17 +43,14 @@ import retrofit2.Response;
public class RoomDetailActivity extends AppCompatActivity {
private static final String TAG = "RoomDetailActivity";
public static final String EXTRA_ROOM_ID = "extra_room_id";
private ActivityRoomDetailNewBinding binding;
private final Handler handler = new Handler(Looper.getMainLooper());
private Runnable pollRunnable;
private Runnable chatSimulationRunnable;
private Runnable chatPollRunnable;
private String roomId;
private String visitorId; // 游客ID
private Room room;
private ExoPlayer player;
@ -93,33 +84,33 @@ public class RoomDetailActivity extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Log.d(TAG, "onCreate called");
try {
// 隐藏ActionBar使用自定义顶部栏
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
binding = ActivityRoomDetailNewBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
ApiClient.getService(getApplicationContext());
roomId = getIntent().getStringExtra(EXTRA_ROOM_ID);
visitorId = UUID.randomUUID().toString().substring(0, 8); // 生成游客ID
triedAltUrl = false;
setupUI();
setupChat();
// 添加欢迎消息
addChatMessage(new ChatMessage("欢迎来到直播间!", true));
} catch (Exception e) {
Log.e(TAG, "onCreate exception, calling finish(): " + e.getMessage(), e);
Toast.makeText(this, "初始化失败: " + e.getMessage(), Toast.LENGTH_LONG).show();
finish();
// 隐藏ActionBar使用自定义顶部栏
if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
binding = ActivityRoomDetailNewBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
ApiClient.getService(getApplicationContext());
roomId = getIntent().getStringExtra(EXTRA_ROOM_ID);
triedAltUrl = false;
setupUI();
setupChat();
// TODO: 接入后端接口 - 记录观看历史
// 接口路径: POST /api/watch/history
// 请求参数:
// - userId: 当前用户ID从token中获取
// - roomId: 房间ID
// - watchTime: 观看时间时间戳可选
// 返回数据格式: ApiResponse<{success: boolean}>
// 进入房间时调用用于记录用户观看历史
// 添加欢迎消息
addChatMessage(new ChatMessage("欢迎来到直播间!", true));
}
private void setupUI() {
@ -130,7 +121,6 @@ public class RoomDetailActivity extends AppCompatActivity {
if (isFullscreen) {
toggleFullscreen();
} else {
Log.d(TAG, "exitFullscreenButton clicked in non-fullscreen mode, calling finish()");
finish();
}
});
@ -149,7 +139,23 @@ public class RoomDetailActivity extends AppCompatActivity {
binding.fullscreenButton.setOnClickListener(v -> toggleFullscreen());
// 关注按钮
binding.followButton.setOnClickListener(v -> followStreamer());
// TODO: 接入后端接口 - 关注/取消关注主播
// 接口路径: POST /api/follow DELETE /api/follow
// 请求参数:
// - streamerId: 主播用户ID
// - action: "follow" "unfollow"
// 返回数据格式: ApiResponse<{success: boolean, message: string}>
// 关注成功后更新按钮状态为"已关注"并禁用按钮
binding.followButton.setOnClickListener(v -> {
// 检查登录状态关注主播需要登录
if (!AuthHelper.requireLogin(this, "关注主播需要登录")) {
return;
}
Toast.makeText(this, "已关注主播", Toast.LENGTH_SHORT).show();
binding.followButton.setText("已关注");
binding.followButton.setEnabled(false);
});
// 分享按钮
binding.shareButton.setOnClickListener(v -> shareRoom());
@ -178,105 +184,57 @@ public class RoomDetailActivity extends AppCompatActivity {
}
private void sendMessage() {
// 检查登录状态发送弹幕需要登录
if (!AuthHelper.requireLoginWithToast(this, "发送弹幕需要登录")) {
return;
}
// TODO: 接入后端接口 - 发送直播间弹幕消息
// 接口路径: POST /api/rooms/{roomId}/messages
// 请求参数:
// - roomId: 房间ID路径参数
// - message: 消息内容
// - userId: 发送者用户ID从token中获取
// 返回数据格式: ApiResponse<ChatMessage>
// ChatMessage对象应包含: messageId, userId, username, avatarUrl, message, timestamp等字段
// 发送成功后将消息添加到本地列表并显示
String message = binding.chatInput.getText() != null ?
binding.chatInput.getText().toString().trim() : "";
if (TextUtils.isEmpty(message) || TextUtils.isEmpty(roomId)) return;
// 先清空输入框显示本地消息
binding.chatInput.setText("");
addChatMessage(new ChatMessage("", message));
// 发送到后端
Map<String, String> body = new HashMap<>();
body.put("message", message);
body.put("visitorId", visitorId);
body.put("nickname", "游客" + visitorId);
ApiClient.getService(getApplicationContext())
.sendMessage(roomId, body)
.enqueue(new Callback<ApiResponse<ChatMessageResponse>>() {
@Override
public void onResponse(Call<ApiResponse<ChatMessageResponse>> call,
Response<ApiResponse<ChatMessageResponse>> response) {
// 发送成功无需额外处理已在本地显示
if (!response.isSuccessful()) {
Log.w(TAG, "sendMessage failed: " + response.code());
}
}
@Override
public void onFailure(Call<ApiResponse<ChatMessageResponse>> call, Throwable t) {
Log.e(TAG, "sendMessage error: " + t.getMessage());
}
});
}
private boolean isFollowing = false;
private void followStreamer() {
if (TextUtils.isEmpty(roomId)) return;
String action = isFollowing ? "unfollow" : "follow";
Map<String, Object> body = new HashMap<>();
body.put("streamerId", roomId);
body.put("action", action);
ApiClient.getService(getApplicationContext())
.followStreamer(body)
.enqueue(new Callback<ApiResponse<Map<String, Object>>>() {
@Override
public void onResponse(Call<ApiResponse<Map<String, Object>>> call,
Response<ApiResponse<Map<String, Object>>> response) {
if (isFinishing() || isDestroyed() || binding == null) return;
if (response.isSuccessful() && response.body() != null && response.body().isOk()) {
isFollowing = !isFollowing;
updateFollowButton();
String msg = isFollowing ? "关注成功" : "已取消关注";
Toast.makeText(RoomDetailActivity.this, msg, Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(RoomDetailActivity.this, "操作失败", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<ApiResponse<Map<String, Object>>> call, Throwable t) {
if (isFinishing() || isDestroyed()) return;
Toast.makeText(RoomDetailActivity.this, "网络错误", Toast.LENGTH_SHORT).show();
}
});
}
private void updateFollowButton() {
if (binding == null) return;
if (isFollowing) {
binding.followButton.setText("已关注");
binding.followButton.setIconResource(0);
} else {
binding.followButton.setText("关注");
binding.followButton.setIconResource(R.drawable.ic_heart_24);
if (!TextUtils.isEmpty(message)) {
addChatMessage(new ChatMessage("", message));
binding.chatInput.setText("");
// TODO: 接入后端接口 - 接收直播间弹幕消息WebSocket或轮询
// 方案1: WebSocket实时推送
// - 连接: ws://api.example.com/rooms/{roomId}/chat
// - 接收消息格式: {type: "message", data: ChatMessage}
// 方案2: 轮询获取新消息
// - 接口路径: GET /api/rooms/{roomId}/messages?lastMessageId={lastId}
// - 返回数据格式: ApiResponse<List<ChatMessage>>
// - 每3-5秒轮询一次获取lastMessageId之后的新消息
// 模拟其他用户回复
handler.postDelayed(() -> {
if (random.nextFloat() < 0.3f) { // 30%概率有人回复
String user = simulatedUsers[random.nextInt(simulatedUsers.length)];
String reply = simulatedMessages[random.nextInt(simulatedMessages.length)];
addChatMessage(new ChatMessage(user, reply));
}
}, 1000 + random.nextInt(3000));
}
}
private void addChatMessage(ChatMessage message) {
try {
// 限制消息数量防止内存溢出
if (chatMessages.size() > 100) {
chatMessages.remove(0);
}
chatMessages.add(message);
if (chatAdapter != null) {
chatAdapter.submitList(new ArrayList<>(chatMessages));
}
// 滚动到最新消息
if (binding != null && binding.chatRecyclerView != null && chatMessages.size() > 0) {
binding.chatRecyclerView.scrollToPosition(chatMessages.size() - 1);
}
} catch (Exception e) {
e.printStackTrace();
// 限制消息数量防止内存溢出
if (chatMessages.size() > 100) {
chatMessages.remove(0);
}
chatMessages.add(message);
chatAdapter.submitList(new ArrayList<>(chatMessages));
// 滚动到最新消息
if (binding != null && binding.chatRecyclerView != null && chatMessages.size() > 0) {
binding.chatRecyclerView.scrollToPosition(chatMessages.size() - 1);
}
}
@ -306,17 +264,16 @@ public class RoomDetailActivity extends AppCompatActivity {
@Override
protected void onStart() {
super.onStart();
Log.d(TAG, "onStart called");
startPolling();
startChatPolling();
startChatSimulation();
}
@Override
protected void onStop() {
super.onStop();
Log.d(TAG, "onStop called");
stopPolling();
stopChatPolling();
stopChatSimulation();
releasePlayer();
}
@Override
@ -339,26 +296,18 @@ public class RoomDetailActivity extends AppCompatActivity {
}
private void startPolling() {
try {
stopPolling();
// 首次立即获取房间信息
fetchRoom();
// 暂时禁用轮询避免重复请求导致的问题
// pollRunnable = () -> {
// try {
// if (!isFinishing() && !isDestroyed()) {
// fetchRoom();
// handler.postDelayed(pollRunnable, 15000);
// }
// } catch (Exception e) {
// e.printStackTrace();
// }
// };
// handler.postDelayed(pollRunnable, 15000);
} catch (Exception e) {
e.printStackTrace();
}
stopPolling();
// 首次立即获取房间信息
fetchRoom();
pollRunnable = () -> {
if (!isFinishing() && !isDestroyed()) {
fetchRoom();
handler.postDelayed(pollRunnable, 15000); // 15秒轮询一次减少压力
}
};
// 延迟15秒后开始轮询
handler.postDelayed(pollRunnable, 15000);
}
private void stopPolling() {
@ -404,140 +353,57 @@ public class RoomDetailActivity extends AppCompatActivity {
}
}
// 弹幕轮询功能
private long lastMessageTimestamp = 0;
private void startChatPolling() {
stopChatPolling();
if (TextUtils.isEmpty(roomId)) return;
// 首次获取历史弹幕
fetchMessages();
chatPollRunnable = () -> {
if (!isFinishing() && !isDestroyed()) {
fetchMessages();
handler.postDelayed(chatPollRunnable, 5000); // 每5秒轮询一次
}
};
handler.postDelayed(chatPollRunnable, 5000);
}
private void stopChatPolling() {
if (chatPollRunnable != null) {
handler.removeCallbacks(chatPollRunnable);
chatPollRunnable = null;
}
}
private void fetchMessages() {
if (TextUtils.isEmpty(roomId)) return;
ApiClient.getService(getApplicationContext())
.getMessages(roomId, 50)
.enqueue(new Callback<ApiResponse<List<ChatMessageResponse>>>() {
@Override
public void onResponse(Call<ApiResponse<List<ChatMessageResponse>>> call,
Response<ApiResponse<List<ChatMessageResponse>>> response) {
if (isFinishing() || isDestroyed()) return;
if (!response.isSuccessful() || response.body() == null) return;
List<ChatMessageResponse> messages = response.body().getData();
if (messages == null || messages.isEmpty()) return;
// 只显示新消息时间戳大于上次的
for (ChatMessageResponse msg : messages) {
if (msg.getTimestamp() > lastMessageTimestamp) {
// 不显示自己发的消息已经本地显示过了
if (!visitorId.equals(msg.getVisitorId())) {
String nickname = msg.getNickname() != null ? msg.getNickname() : "游客";
addChatMessage(new ChatMessage(nickname, msg.getContent()));
}
lastMessageTimestamp = msg.getTimestamp();
}
}
}
@Override
public void onFailure(Call<ApiResponse<List<ChatMessageResponse>>> call, Throwable t) {
Log.e(TAG, "fetchMessages error: " + t.getMessage());
}
});
}
private boolean isFirstLoad = true;
private void fetchRoom() {
try {
// TODO: 接入后端接口 - 获取房间详情
if (TextUtils.isEmpty(roomId)) {
Log.e(TAG, "fetchRoom: roomId is empty, calling finish()");
Toast.makeText(this, "房间不存在", Toast.LENGTH_SHORT).show();
finish();
return;
}
// 只在首次加载时显示loading
if (isFirstLoad && binding != null) {
binding.loading.setVisibility(View.VISIBLE);
}
ApiClient.getService(getApplicationContext()).getRoom(roomId).enqueue(new Callback<ApiResponse<Room>>() {
@Override
public void onResponse(Call<ApiResponse<Room>> call, Response<ApiResponse<Room>> response) {
try {
if (isFinishing() || isDestroyed()) return;
if (binding != null) {
binding.loading.setVisibility(View.GONE);
}
boolean firstLoad = isFirstLoad;
isFirstLoad = false;
ApiResponse<Room> body = response.body();
Room data = body != null ? body.getData() : null;
if (!response.isSuccessful() || body == null || !body.isOk() || data == null) {
if (firstLoad) {
String msg = body != null && !TextUtils.isEmpty(body.getMessage()) ? body.getMessage() : "暂无直播";
// 显示离线状态而不是退出
if (binding != null) {
binding.offlineLayout.setVisibility(View.VISIBLE);
binding.topTitle.setText("直播间");
}
Toast.makeText(RoomDetailActivity.this, msg, Toast.LENGTH_SHORT).show();
}
return;
}
room = data;
bindRoom(room);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void onFailure(Call<ApiResponse<Room>> call, Throwable t) {
try {
if (isFinishing() || isDestroyed()) return;
if (binding != null) {
binding.loading.setVisibility(View.GONE);
// 网络错误时显示离线状态
if (isFirstLoad) {
binding.offlineLayout.setVisibility(View.VISIBLE);
binding.topTitle.setText("直播间");
Toast.makeText(RoomDetailActivity.this, "网络连接失败", Toast.LENGTH_SHORT).show();
}
}
isFirstLoad = false;
} catch (Exception e) {
e.printStackTrace();
}
}
});
} catch (Exception e) {
e.printStackTrace();
// TODO: 接入后端接口 - 获取房间详情
// 接口路径: GET /api/rooms/{roomId}
// 请求参数: roomId (路径参数)
// 返回数据格式: ApiResponse<Room>
// Room对象应包含: id, title, streamerName, streamerId, type, isLive, coverUrl, viewerCount,
// streamUrls (包含flv, hls, rtmp地址), description, startTime等字段
if (TextUtils.isEmpty(roomId)) {
Toast.makeText(this, "房间不存在", Toast.LENGTH_SHORT).show();
finish();
return;
}
// 只在首次加载时显示loading
if (isFirstLoad) {
binding.loading.setVisibility(View.VISIBLE);
}
ApiClient.getService(getApplicationContext()).getRoom(roomId).enqueue(new Callback<ApiResponse<Room>>() {
@Override
public void onResponse(Call<ApiResponse<Room>> call, Response<ApiResponse<Room>> response) {
if (isFinishing() || isDestroyed()) return;
binding.loading.setVisibility(View.GONE);
boolean firstLoad = isFirstLoad;
isFirstLoad = false;
ApiResponse<Room> body = response.body();
Room data = body != null ? body.getData() : null;
if (!response.isSuccessful() || body == null || !body.isOk() || data == null) {
if (firstLoad) {
String msg = body != null && !TextUtils.isEmpty(body.getMessage()) ? body.getMessage() : "房间不存在";
Toast.makeText(RoomDetailActivity.this, msg, Toast.LENGTH_SHORT).show();
finish();
}
return;
}
room = data;
bindRoom(room);
}
@Override
public void onFailure(Call<ApiResponse<Room>> call, Throwable t) {
if (isFinishing() || isDestroyed()) return;
binding.loading.setVisibility(View.GONE);
isFirstLoad = false;
}
});
}
private void shareRoom() {
@ -553,68 +419,63 @@ public class RoomDetailActivity extends AppCompatActivity {
}
private void bindRoom(Room r) {
try {
if (r == null || binding == null) return;
String title = r.getTitle() != null ? r.getTitle() : "直播间";
String streamer = r.getStreamerName() != null ? r.getStreamerName() : "主播";
// 设置顶部标题栏
binding.topTitle.setText(title);
// 设置房间信息区域
binding.roomTitle.setText(title);
binding.streamerName.setText(streamer);
String title = r.getTitle() != null ? r.getTitle() : "直播间";
String streamer = r.getStreamerName() != null ? r.getStreamerName() : "主播";
// 设置顶部标题栏
binding.topTitle.setText(title);
// 设置房间信息区域
binding.roomTitle.setText(title);
binding.streamerName.setText(streamer);
// 设置直播状态
if (r.isLive()) {
binding.liveTag.setVisibility(View.VISIBLE);
binding.offlineLayout.setVisibility(View.GONE);
// 设置观看人数
int viewerCount = r.getViewerCount() > 0 ? r.getViewerCount() :
100 + random.nextInt(500);
binding.topViewerCount.setText(String.valueOf(viewerCount));
} else {
binding.liveTag.setVisibility(View.GONE);
binding.offlineLayout.setVisibility(View.VISIBLE);
releasePlayer();
return;
}
// 设置直播状态
if (r.isLive()) {
binding.liveTag.setVisibility(View.VISIBLE);
binding.offlineLayout.setVisibility(View.GONE);
// TODO: 接入后端接口 - 获取实时观看人数
// 接口路径: GET /api/rooms/{roomId}/viewers/count
// 请求参数: roomId (路径参数)
// 返回数据格式: ApiResponse<{viewerCount: number}>
// 建议使用WebSocket实时推送观看人数变化或每10-15秒轮询一次
// 设置观看人数模拟
int viewerCount = r.getViewerCount() > 0 ? r.getViewerCount() :
100 + random.nextInt(500);
binding.topViewerCount.setText(String.valueOf(viewerCount));
} else {
binding.liveTag.setVisibility(View.GONE);
binding.offlineLayout.setVisibility(View.VISIBLE);
releasePlayer();
return;
}
// 获取播放地址
String playUrl = null;
String fallbackHlsUrl = null;
if (r.getStreamUrls() != null) {
// 优先使用HTTP-FLV延迟更低
playUrl = r.getStreamUrls().getFlv();
fallbackHlsUrl = r.getStreamUrls().getHls();
if (TextUtils.isEmpty(playUrl)) playUrl = fallbackHlsUrl;
}
// 获取播放地址
String playUrl = null;
String fallbackHlsUrl = null;
if (r.getStreamUrls() != null) {
// 优先使用HTTP-FLV延迟更低
playUrl = r.getStreamUrls().getFlv();
fallbackHlsUrl = r.getStreamUrls().getHls();
if (TextUtils.isEmpty(playUrl)) playUrl = fallbackHlsUrl;
}
if (!TextUtils.isEmpty(playUrl)) {
ensurePlayer(playUrl, fallbackHlsUrl);
} else {
// 没有播放地址时显示离线状态
binding.offlineLayout.setVisibility(View.VISIBLE);
releasePlayer();
}
} catch (Exception e) {
e.printStackTrace();
if (!TextUtils.isEmpty(playUrl)) {
ensurePlayer(playUrl, fallbackHlsUrl);
} else {
// 没有播放地址时显示离线状态
binding.offlineLayout.setVisibility(View.VISIBLE);
releasePlayer();
}
}
private void ensurePlayer(String url, String fallbackHlsUrl) {
if (TextUtils.isEmpty(url)) return;
Log.d(TAG, "ensurePlayer: url=" + url + ", fallbackHlsUrl=" + fallbackHlsUrl);
// 暂时禁用 ijkplayer使用 ExoPlayer 播放 HLS
// 如果是 FLV 使用 fallback HLS 地址
String playUrl = url;
if (url.endsWith(".flv") && !TextUtils.isEmpty(fallbackHlsUrl)) {
playUrl = fallbackHlsUrl;
Log.d(TAG, "ensurePlayer: FLV detected, using HLS fallback: " + playUrl);
if (url.endsWith(".flv")) {
if (TextUtils.equals(ijkUrl, url) && ijkPlayer != null) return;
startFlv(url, fallbackHlsUrl);
return;
}
if (player != null) {
@ -623,36 +484,32 @@ public class RoomDetailActivity extends AppCompatActivity {
? current.localConfiguration.uri.toString()
: null;
if (currentUri != null && currentUri.equals(playUrl)) return;
if (currentUri != null && currentUri.equals(url)) return;
}
startHls(playUrl, null);
startHls(url, null);
}
private void startHls(String url, @Nullable String altUrl) {
if (binding == null) return;
releaseIjkPlayer();
binding.flvTextureView.setVisibility(View.GONE);
binding.playerView.setVisibility(View.VISIBLE);
if (binding != null) {
binding.flvTextureView.setVisibility(View.GONE);
binding.playerView.setVisibility(View.VISIBLE);
}
releaseExoPlayer();
triedAltUrl = false;
// 低延迟配置最小缓冲快速开始播放
ExoPlayer exo = new ExoPlayer.Builder(this)
.setLoadControl(new androidx.media3.exoplayer.DefaultLoadControl.Builder()
.setBufferDurationsMs(
500, // 最小缓冲时间
1500, // 最大缓冲时间
250, // 播放前缓冲
500 // 重新缓冲时间
1000,
3000,
500,
1000
)
.build())
.build();
// 设置为直播模式跳到最新位置
exo.setPlayWhenReady(true);
binding.playerView.setPlayer(exo);
@ -663,7 +520,6 @@ public class RoomDetailActivity extends AppCompatActivity {
exo.addListener(new Player.Listener() {
@Override
public void onPlayerError(PlaybackException error) {
if (isFinishing() || isDestroyed() || binding == null) return;
if (triedAltUrl || TextUtils.isEmpty(finalComputedAltUrl)) {
binding.offlineLayout.setVisibility(View.VISIBLE);
return;
@ -676,7 +532,6 @@ public class RoomDetailActivity extends AppCompatActivity {
@Override
public void onPlaybackStateChanged(int playbackState) {
if (isFinishing() || isDestroyed() || binding == null) return;
if (playbackState == Player.STATE_READY) {
binding.offlineLayout.setVisibility(View.GONE);
addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true));
@ -684,25 +539,13 @@ public class RoomDetailActivity extends AppCompatActivity {
}
});
// 使用直播配置自动跳到最新位置
MediaItem mediaItem = new MediaItem.Builder()
.setUri(url)
.setLiveConfiguration(new MediaItem.LiveConfiguration.Builder()
.setMaxPlaybackSpeed(1.02f) // 轻微加速追赶直播
.build())
.build();
exo.setMediaItem(mediaItem);
exo.setMediaItem(MediaItem.fromUri(url));
exo.prepare();
// 跳到直播最新位置
exo.seekToDefaultPosition();
exo.setPlayWhenReady(true);
player = exo;
}
private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) {
if (binding == null) return;
ensureIjkLibsLoaded();
releaseExoPlayer();
releaseIjkPlayer();
@ -711,8 +554,10 @@ public class RoomDetailActivity extends AppCompatActivity {
ijkFallbackHlsUrl = fallbackHlsUrl;
ijkFallbackTried = false;
binding.playerView.setVisibility(View.GONE);
binding.flvTextureView.setVisibility(View.VISIBLE);
if (binding != null) {
binding.playerView.setVisibility(View.GONE);
binding.flvTextureView.setVisibility(View.VISIBLE);
}
TextureView view = binding.flvTextureView;
TextureView.SurfaceTextureListener listener = new TextureView.SurfaceTextureListener() {
@ -728,7 +573,6 @@ public class RoomDetailActivity extends AppCompatActivity {
@Override
public boolean onSurfaceTextureDestroyed(@NonNull android.graphics.SurfaceTexture surface) {
Log.w(TAG, "onSurfaceTextureDestroyed called");
releaseIjkPlayer();
return true;
}
@ -759,16 +603,12 @@ public class RoomDetailActivity extends AppCompatActivity {
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "infbuf", 1);
p.setOnPreparedListener(mp -> {
Log.d(TAG, "IjkPlayer onPrepared");
if (isFinishing() || isDestroyed() || binding == null) return;
binding.offlineLayout.setVisibility(View.GONE);
mp.start();
addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true));
});
p.setOnErrorListener((IMediaPlayer mp, int what, int extra) -> {
Log.e(TAG, "IjkPlayer onError: what=" + what + ", extra=" + extra);
if (isFinishing() || isDestroyed() || binding == null) return true;
if (ijkFallbackTried || TextUtils.isEmpty(ijkFallbackHlsUrl)) {
binding.offlineLayout.setVisibility(View.VISIBLE);
return true;
@ -786,7 +626,7 @@ public class RoomDetailActivity extends AppCompatActivity {
} catch (Exception e) {
if (!TextUtils.isEmpty(ijkFallbackHlsUrl)) {
startHls(ijkFallbackHlsUrl, null);
} else if (binding != null) {
} else {
binding.offlineLayout.setVisibility(View.VISIBLE);
}
}
@ -844,7 +684,6 @@ public class RoomDetailActivity extends AppCompatActivity {
@Override
protected void onDestroy() {
super.onDestroy();
Log.d(TAG, "onDestroy called");
// 确保Handler回调被清理防止内存泄漏
if (handler != null) {

View File

@ -72,6 +72,19 @@ public class RoomsAdapter extends ListAdapter<Room, RoomsAdapter.RoomVH> {
binding.roomTitle.setText(room != null && room.getTitle() != null ? room.getTitle() : "(Untitled)");
binding.streamerName.setText(room != null && room.getStreamerName() != null ? room.getStreamerName() : "");
// TODO: 接入后端接口 - 获取房间点赞数
// 接口路径: GET /api/rooms/{roomId}/likes/count
// 请求参数: roomId (路径参数)
// 返回数据格式: ApiResponse<{likeCount: number}>
// 或者Room对象应包含likeCount字段直接从room.getLikeCount()获取
// TODO: 接入后端接口 - 点赞/取消点赞房间
// 接口路径: POST /api/rooms/{roomId}/like DELETE /api/rooms/{roomId}/like
// 请求参数:
// - roomId: 房间ID路径参数
// - userId: 用户ID从token中获取
// - action: "like" "unlike"
// 返回数据格式: ApiResponse<{success: boolean, likeCount: number, isLiked: boolean}>
// 点赞成功后更新本地点赞数和点赞状态
try {
String seed = room != null && room.getId() != null ? room.getId() : String.valueOf(getBindingAdapterPosition());
int h = Math.abs(seed.hashCode());
@ -114,18 +127,27 @@ public class RoomsAdapter extends ListAdapter<Room, RoomsAdapter.RoomVH> {
assetFile = a.coverAssetFiles.get(idx);
}
// TODO: 接入后端接口 - 从后端获取房间封面图片URL
// 接口路径: GET /api/rooms/{roomId}/cover
// 请求参数: roomId (路径参数)
// 返回数据格式: ApiResponse<{coverUrl: string}>
// 或者Room对象应包含coverUrl字段直接从room.getCoverUrl()获取
// 优先使用Room对象中的coverUrl如果没有则使用默认占位图
Object model;
if (assetFile != null) {
model = "file:///android_asset/img/" + assetFile;
} else {
// 使用稳定快速的占位图片基于房间ID生成不同图片
String seed = room != null && room.getId() != null ? room.getId() : String.valueOf(getBindingAdapterPosition());
int h = Math.abs(seed.hashCode());
int imgIndex = (h % 10) + 1;
// 使用 placehold.co 快速加载国内访问快
String[] colors = {"ff6b6b", "4ecdc4", "45b7d1", "96ceb4", "ffeaa7", "dfe6e9", "fd79a8", "a29bfe", "00b894", "e17055"};
String color = colors[imgIndex - 1];
model = "https://placehold.co/600x450/" + color + "/ffffff?text=LIVE";
// 优先从Room对象获取封面URL
String coverUrl = room != null ? room.getCoverUrl() : null;
if (coverUrl != null && !coverUrl.trim().isEmpty()) {
model = coverUrl;
} else {
// 降级方案使用占位图或随机图片
String seed = room != null && room.getId() != null ? room.getId() : String.valueOf(getBindingAdapterPosition());
int h = Math.abs(seed.hashCode());
int imageId = (h % 1000) + 1;
model = "https://picsum.photos/id/" + imageId + "/600/450";
}
}
Glide.with(binding.coverImage)

View File

@ -107,6 +107,23 @@ public class SearchActivity extends AppCompatActivity {
}
private void applyFilter(String q) {
// TODO: 接入后端接口 - 实时搜索建议当用户输入时
// 接口路径: GET /api/search/suggestions
// 请求参数:
// - keyword: 搜索关键词必填
// - limit (可选): 返回数量限制默认10
// 返回数据格式: ApiResponse<List<SearchSuggestion>>
// SearchSuggestion对象应包含: type (room/user), id, title, subtitle, avatarUrl等字段
// 用于在用户输入时显示搜索建议提升搜索体验
// TODO: 接入后端接口 - 搜索功能当用户提交搜索时
// 接口路径: GET /api/search
// 请求参数:
// - keyword: 搜索关键词必填
// - type (可选): 搜索类型room/user/all默认all
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<{rooms: Room[], users: User[], total: number}>
// 搜索逻辑应迁移到后端前端只负责展示结果
String query = q != null ? q.trim() : "";
if (query.isEmpty()) {
adapter.submitList(new ArrayList<>(all));

View File

@ -48,6 +48,12 @@ public class SearchSuggestionsAdapter extends ListAdapter<Room, SearchSuggestion
}
void bind(Room room) {
// TODO: 接入后端接口 - 从后端获取搜索建议的房间封面图片URL
// 接口路径: GET /api/rooms/{roomId}/cover
// 请求参数: roomId (路径参数)
// 返回数据格式: ApiResponse<{coverUrl: string}>
// 或者Room对象应包含coverUrl字段直接从room.getCoverUrl()获取
// 如果搜索建议包含封面图片使用Glide加载否则使用默认占位图
String title = room != null && room.getTitle() != null ? room.getTitle() : "";
String sub = room != null && room.getStreamerName() != null ? room.getStreamerName() : "";
binding.title.setText(title);

View File

@ -45,12 +45,22 @@ public class SettingsPageActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 检查登录状态设置页面需要登录除了服务器设置等通用设置
page = getIntent() != null ? getIntent().getStringExtra(EXTRA_PAGE) : null;
if (page != null && !PAGE_SERVER.equals(page) && !PAGE_ABOUT.equals(page) && !PAGE_HELP.equals(page)) {
if (!AuthHelper.requireLogin(this, "此设置需要登录")) {
finish();
return;
}
}
binding = ActivitySettingsPageBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
binding.backButton.setOnClickListener(v -> finish());
page = getIntent() != null ? getIntent().getStringExtra(EXTRA_PAGE) : null;
// page已在onCreate开始处获取
if (page == null) page = "";
String title = resolveTitle(page);
@ -478,6 +488,14 @@ public class SettingsPageActivity extends AppCompatActivity {
}
private void showChangePasswordDialog() {
// TODO: 接入后端接口 - 修改密码
// 接口路径: POST /api/users/{userId}/password/change
// 请求参数:
// - userId: 用户ID从token中获取
// - oldPassword: 旧密码
// - newPassword: 新密码
// 返回数据格式: ApiResponse<{success: boolean, message: string}>
// 修改成功后提示用户并关闭对话框
EditText oldPassword = new EditText(this);
oldPassword.setHint("请输入旧密码");
oldPassword.setInputType(android.text.InputType.TYPE_CLASS_TEXT | android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD);
@ -503,6 +521,20 @@ public class SettingsPageActivity extends AppCompatActivity {
}
private void showBindPhoneDialog() {
// TODO: 接入后端接口 - 绑定手机号
// 接口路径: POST /api/users/{userId}/phone/bind
// 请求参数:
// - userId: 用户ID从token中获取
// - phone: 手机号
// - verificationCode: 验证码需要先调用发送验证码接口
// 返回数据格式: ApiResponse<{success: boolean, message: string}>
// TODO: 接入后端接口 - 发送手机验证码
// 接口路径: POST /api/sms/send
// 请求参数:
// - phone: 手机号
// - type: 验证码类型bind/change等
// 返回数据格式: ApiResponse<{success: boolean, message: string}>
// 绑定成功后提示用户并关闭对话框
EditText phoneInput = new EditText(this);
phoneInput.setHint("请输入手机号");
phoneInput.setInputType(android.text.InputType.TYPE_CLASS_PHONE);
@ -518,6 +550,19 @@ public class SettingsPageActivity extends AppCompatActivity {
}
private void showDeviceManagementDialog() {
// TODO: 接入后端接口 - 获取登录设备列表
// 接口路径: GET /api/users/{userId}/devices
// 请求参数:
// - userId: 用户ID从token中获取
// 返回数据格式: ApiResponse<List<DeviceInfo>>
// DeviceInfo对象应包含: deviceId, deviceName, deviceType, loginTime, lastActiveTime, isCurrent等字段
// TODO: 接入后端接口 - 退出指定设备登录
// 接口路径: DELETE /api/users/{userId}/devices/{deviceId}
// 请求参数:
// - userId: 用户ID从token中获取
// - deviceId: 设备ID路径参数
// 返回数据格式: ApiResponse<{success: boolean}>
// 退出成功后从设备列表中移除该设备
new AlertDialog.Builder(this)
.setTitle("登录设备管理")
.setMessage("当前登录设备:\n• 本设备(当前)\n\n功能开发中...")
@ -526,6 +571,26 @@ public class SettingsPageActivity extends AppCompatActivity {
}
private void showBlacklistDialog() {
// TODO: 接入后端接口 - 获取黑名单列表
// 接口路径: GET /api/users/{userId}/blacklist
// 请求参数:
// - userId: 用户ID从token中获取
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<User>>
// User对象应包含: id, name, avatarUrl, addToBlacklistTime等字段
// TODO: 接入后端接口 - 添加用户到黑名单
// 接口路径: POST /api/users/{userId}/blacklist
// 请求参数:
// - userId: 用户ID从token中获取
// - targetUserId: 要屏蔽的用户ID
// 返回数据格式: ApiResponse<{success: boolean}>
// TODO: 接入后端接口 - 从黑名单移除用户
// 接口路径: DELETE /api/users/{userId}/blacklist/{targetUserId}
// 请求参数:
// - userId: 用户ID从token中获取
// - targetUserId: 要解除屏蔽的用户ID路径参数
// 返回数据格式: ApiResponse<{success: boolean}>
new AlertDialog.Builder(this)
.setTitle("黑名单管理")
.setMessage("黑名单功能允许您屏蔽不想看到的用户。\n\n" +
@ -655,6 +720,18 @@ public class SettingsPageActivity extends AppCompatActivity {
"A: 请联系客服或通过绑定的手机号找回密码。\n\n" +
"Q7: 如何举报不良内容?\n" +
"A: 在直播间或用户主页可以举报违规内容,我们会及时处理。\n\n" +
// TODO: 接入后端接口 - 举报功能
// 接口路径: POST /api/report
// 请求参数:
// - userId: 当前用户ID从token中获取
// - targetType: 举报类型room/user/message/work等
// - targetId: 被举报对象ID
// - reason: 举报原因可选
// - description: 举报描述可选
// - images (可选): 举报截图URL列表
// 返回数据格式: ApiResponse<{success: boolean, reportId: string}>
// 举报成功后提示用户并记录举报信息
//
"更多问题请联系客服。";
new AlertDialog.Builder(this)
@ -665,6 +742,15 @@ public class SettingsPageActivity extends AppCompatActivity {
}
private void showFeedbackDialog() {
// TODO: 接入后端接口 - 提交意见反馈
// 接口路径: POST /api/feedback
// 请求参数:
// - userId: 用户ID从token中获取可选
// - content: 反馈内容必填
// - contact: 联系方式可选邮箱或手机号
// - images (可选): 反馈图片URL列表
// 返回数据格式: ApiResponse<{success: boolean, feedbackId: string}>
// 提交成功后提示用户并关闭对话框
EditText feedbackInput = new EditText(this);
feedbackInput.setHint("请输入您的意见或建议");
feedbackInput.setMinLines(5);

View File

@ -11,6 +11,11 @@ public class ShareUtils {
/**
* 生成个人主页分享链接
* TODO: 接入后端接口 - 获取个人主页分享链接
* 接口路径: GET /api/share/profile/{userId}
* 请求参数: userId (路径参数)
* 返回数据格式: ApiResponse<{shareLink: string, shareCode: string}>
* 后端可以生成短链接二维码等并记录分享统计
*/
public static String generateProfileShareLink(String userId) {
return "https://livestreaming.com/profile/" + userId;
@ -18,6 +23,11 @@ public class ShareUtils {
/**
* 生成直播间分享链接
* TODO: 接入后端接口 - 获取直播间分享链接
* 接口路径: GET /api/share/room/{roomId}
* 请求参数: roomId (路径参数)
* 返回数据格式: ApiResponse<{shareLink: string, shareCode: string, qrCodeUrl: string}>
* 后端可以生成短链接二维码等并记录分享统计
*/
public static String generateRoomShareLink(String roomId) {
return "https://livestreaming.com/room/" + roomId;
@ -25,6 +35,15 @@ public class ShareUtils {
/**
* 分享链接使用系统分享菜单
* TODO: 接入后端接口 - 记录分享行为
* 接口路径: POST /api/share/record
* 请求参数:
* - userId: 当前用户ID从token中获取可选
* - shareType: 分享类型profile/room/work等
* - targetId: 目标ID用户ID/房间ID/作品ID等
* - sharePlatform: 分享平台wechat/weibo/qq/system等可选
* 返回数据格式: ApiResponse<{success: boolean}>
* 用于统计分享数据和用户行为分析
*/
public static void shareLink(Context context, String link, String title, String text) {
if (context == null || link == null) return;

View File

@ -142,7 +142,21 @@ public class TabPlaceholderActivity extends AppCompatActivity {
binding.discoverShortcutLive.setOnClickListener(v -> Toast.makeText(this, "进入直播模块", Toast.LENGTH_SHORT).show());
binding.discoverShortcutNearby.setOnClickListener(v -> TabPlaceholderActivity.start(this, "附近"));
// TODO: 接入后端接口 - 获取榜单数据
// 接口路径: GET /api/rankings
// 请求参数:
// - type (可选): 榜单类型hot/new/rich等
// - category (可选): 分类筛选
// 返回数据格式: ApiResponse<List<RankingItem>>
// RankingItem对象应包含: rank, roomId, title, streamerName, viewerCount, likeCount等字段
binding.discoverShortcutRank.setOnClickListener(v -> Toast.makeText(this, "榜单功能待接入", Toast.LENGTH_SHORT).show());
// TODO: 接入后端接口 - 获取话题列表
// 接口路径: GET /api/topics
// 请求参数:
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<TopicItem>>
// TopicItem对象应包含: id, title, coverUrl, participantCount, postCount, hotScore等字段
binding.discoverShortcutTopic.setOnClickListener(v -> Toast.makeText(this, "话题功能待接入", Toast.LENGTH_SHORT).show());
}
@ -180,6 +194,13 @@ public class TabPlaceholderActivity extends AppCompatActivity {
}
private void applyDiscoverFilter(String q) {
// TODO: 接入后端接口 - 发现页搜索建议
// 接口路径: GET /api/discover/search/suggestions
// 请求参数:
// - keyword: 搜索关键词必填
// - limit (可选): 返回数量限制默认10
// 返回数据格式: ApiResponse<List<Room>>
// 用于在发现页搜索框输入时显示实时搜索建议
String query = q != null ? q.trim() : "";
if (TextUtils.isEmpty(query)) {
binding.discoverSuggestionsRecyclerView.setVisibility(View.GONE);
@ -227,6 +248,13 @@ public class TabPlaceholderActivity extends AppCompatActivity {
ensureFollowRoomsAdapter();
// TODO: 接入后端接口 - 获取关注主播的直播间列表TabPlaceholderActivity中的关注页面
// 接口路径: GET /api/following/rooms
// 请求参数:
// - userId: 当前用户ID从token中获取
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<Room>>
// 初始化数据
followAllRooms.clear();
followAllRooms.addAll(buildFollowDemoRooms(16));
@ -327,6 +355,14 @@ public class TabPlaceholderActivity extends AppCompatActivity {
binding.nearbyRecyclerView.setLayoutManager(layoutManager);
binding.nearbyRecyclerView.setAdapter(adapter);
// TODO: 接入后端接口 - 获取附近用户列表TabPlaceholderActivity中的附近页面
// 接口路径: GET /api/users/nearby
// 请求参数:
// - latitude: 当前用户纬度必填
// - longitude: 当前用户经度必填
// - radius (可选): 搜索半径单位默认5000
// - page (可选): 页码
// 返回数据格式: ApiResponse<List<NearbyUser>>
adapter.submitList(buildNearbyDemoUsers(18));
}
@ -407,6 +443,12 @@ public class TabPlaceholderActivity extends AppCompatActivity {
binding.parkBadgeRecyclerView.setLayoutManager(new GridLayoutManager(this, 3));
binding.parkBadgeRecyclerView.setAdapter(badgesAdapter);
// TODO: 接入后端接口 - 获取用户勋章列表
// 接口路径: GET /api/users/{userId}/badges
// 请求参数:
// - userId: 用户ID从token中获取
// 返回数据格式: ApiResponse<List<BadgeItem>>
// BadgeItem对象应包含: id, name, desc, iconUrl, isAchieved, isLocked, achieveTime等字段
badgesAdapter.submitList(buildDemoBadges());
}
@ -548,14 +590,21 @@ public class TabPlaceholderActivity extends AppCompatActivity {
}
private void showLogoutConfirm() {
// TODO: 接入后端接口 - 退出登录
// 接口路径: POST /api/logout GET /api/logout
// 请求参数: 从token中获取userId
// 返回数据格式: ApiResponse<{success: boolean}>
// 退出成功后清除本地token和用户信息跳转到登录页面
new AlertDialog.Builder(this)
.setTitle("退出登录")
.setMessage("确定要退出登录吗?")
.setNegativeButton("取消", null)
.setPositiveButton("退出", (d, w) -> {
Intent intent = new Intent(this, MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
// 清除token
com.example.livestreaming.net.AuthStore.setToken(getApplicationContext(), null);
// 跳转到登录页面
LoginActivity.start(TabPlaceholderActivity.this);
finish();
})
.show();
@ -572,9 +621,34 @@ public class TabPlaceholderActivity extends AppCompatActivity {
setFollowContainerVisibility(View.GONE);
binding.nearbyRecyclerView.setVisibility(View.GONE);
// TODO: 接入后端接口 - 刷新定位并获取附近数据
// 接口路径: POST /api/users/{userId}/location/update
// 请求参数:
// - userId: 用户ID从token中获取
// - latitude: 当前用户纬度
// - longitude: 当前用户经度
// 返回数据格式: ApiResponse<{success: boolean}>
// 更新成功后重新获取附近用户附近直播热门地点等数据
binding.locationRefresh.setOnClickListener(v -> Toast.makeText(this, "刷新定位(待接入)", Toast.LENGTH_SHORT).show());
binding.nearbyEntryUsers.setOnClickListener(v -> TabPlaceholderActivity.start(this, "附近"));
// TODO: 接入后端接口 - 获取附近直播列表
// 接口路径: GET /api/rooms/nearby
// 请求参数:
// - latitude: 当前用户纬度必填
// - longitude: 当前用户经度必填
// - radius (可选): 搜索半径单位默认10000
// - page (可选): 页码
// 返回数据格式: ApiResponse<List<Room>>
// 返回距离用户最近的正在直播的房间列表
binding.nearbyEntryLive.setOnClickListener(v -> Toast.makeText(this, "附近直播(待接入)", Toast.LENGTH_SHORT).show());
// TODO: 接入后端接口 - 获取热门地点列表
// 接口路径: GET /api/locations/hot
// 请求参数:
// - latitude: 当前用户纬度可选用于排序
// - longitude: 当前用户经度可选用于排序
// - page (可选): 页码
// 返回数据格式: ApiResponse<List<LocationItem>>
// LocationItem对象应包含: id, name, address, latitude, longitude, distance, liveCount, userCount等字段
binding.nearbyEntryPlaces.setOnClickListener(v -> Toast.makeText(this, "热门地点(待接入)", Toast.LENGTH_SHORT).show());
}
@ -597,12 +671,36 @@ public class TabPlaceholderActivity extends AppCompatActivity {
addFriendAdapter = new NearbyUsersAdapter(user -> {
if (user == null) return;
// 检查登录状态加好友需要登录
if (!AuthHelper.requireLogin(this, "加好友需要登录")) {
return;
}
// TODO: 接入后端接口 - 发送好友请求
// 接口路径: POST /api/friends/request
// 请求参数:
// - userId: 当前用户ID从token中获取
// - targetUserId: 目标用户ID
// 返回数据格式: ApiResponse<{success: boolean, message: string}>
// 发送成功后提示用户并更新按钮状态
Toast.makeText(this, "已发送好友请求:" + user.getName(), Toast.LENGTH_SHORT).show();
});
binding.addFriendRecyclerView.setLayoutManager(new LinearLayoutManager(this));
binding.addFriendRecyclerView.setAdapter(addFriendAdapter);
// TODO: 接入后端接口 - 获取推荐好友列表加好友页面
// 接口路径: GET /api/friends/recommend
// 请求参数:
// - userId: 当前用户ID从token中获取
// - latitude (可选): 当前用户纬度用于推荐附近的人
// - longitude (可选): 当前用户经度用于推荐附近的人
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<User>>
// User对象应包含: id, name, avatarUrl, bio, location, distance, mutualFriendsCount等字段
// 推荐算法基于共同好友地理位置兴趣标签等
addFriendAllUsers.clear();
addFriendAllUsers.addAll(buildNearbyDemoUsers(18));
addFriendAdapter.submitList(new ArrayList<>(addFriendAllUsers));

View File

@ -13,6 +13,11 @@ public class UnreadMessageManager {
/**
* 获取总未读消息数量
* TODO: 接入后端接口 - 从后端获取未读消息总数
* 接口路径: GET /api/messages/unread/count
* 请求参数: 从token中获取userId
* 返回数据格式: ApiResponse<{unreadCount: number}>
* 建议在应用启动时和进入后台后重新进入时调用此接口更新未读数量
*/
public static int getUnreadCount(Context context) {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);

View File

@ -132,6 +132,27 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity {
}
private void showTab(int index) {
// TODO: 接入后端接口 - 获取其他用户的作品列表
// 接口路径: GET /api/users/{userId}/works
// 请求参数:
// - userId: 用户ID路径参数
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<WorkItem>>
// TODO: 接入后端接口 - 获取其他用户的收藏列表
// 接口路径: GET /api/users/{userId}/favorites
// 请求参数:
// - userId: 用户ID路径参数
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<WorkItem>>
// TODO: 接入后端接口 - 获取其他用户赞过的作品列表
// 接口路径: GET /api/users/{userId}/liked
// 请求参数:
// - userId: 用户ID路径参数
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<WorkItem>>
if (binding == null) return;
// 标签页顺序0-作品, 1-收藏, 2-赞过
binding.worksRecycler.setVisibility(index == 0 ? android.view.View.VISIBLE : android.view.View.GONE);

View File

@ -15,6 +15,15 @@ public class UserWorksAdapter extends RecyclerView.Adapter<UserWorksAdapter.VH>
private final List<Integer> items = new ArrayList<>();
// TODO: 接入后端接口 - 加载用户作品数据
// 接口路径: GET /api/users/{userId}/works
// 请求参数:
// - userId: 用户ID路径参数
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<WorkItem>>
// WorkItem对象应包含: id, coverUrl, title, likeCount, viewCount, publishTime等字段
// 注意当前使用Integer列表drawable资源ID作为临时方案应改为使用WorkItem对象列表
public void submitList(List<Integer> list) {
items.clear();
if (list != null) items.addAll(list);

View File

@ -68,6 +68,20 @@ public class WaterfallRoomsAdapter extends ListAdapter<Room, WaterfallRoomsAdapt
}
void bind(Room room) {
// TODO: 接入后端接口 - 从后端获取房间封面图片URL
// 接口路径: GET /api/rooms/{roomId}/cover
// 请求参数: roomId (路径参数)
// 返回数据格式: ApiResponse<{coverUrl: string}>
// 或者Room对象应包含coverUrl字段直接从room.getCoverUrl()获取
// TODO: 接入后端接口 - 从后端获取主播头像URL
// 接口路径: GET /api/user/profile/{streamerId}
// 请求参数: streamerId (路径参数从Room对象中获取streamerId)
// 返回数据格式: ApiResponse<{avatarUrl: string}>
// TODO: 接入后端接口 - 获取房间观看人数
// 接口路径: GET /api/rooms/{roomId}/viewers/count
// 请求参数: roomId (路径参数)
// 返回数据格式: ApiResponse<{viewerCount: number}>
// 或者Room对象应包含viewerCount字段直接从room.getViewerCount()获取
if (room == null) return;
// 设置标题

View File

@ -92,6 +92,13 @@ public class WishTreeActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// TODO: 接入后端接口 - 获取愿望树相关数据
// 接口路径: GET /api/wish_tree/info
// 请求参数:
// - userId: 当前用户ID从token中获取可选
// 返回数据格式: ApiResponse<WishTreeInfo>
// WishTreeInfo对象应包含: totalWishes, todayWishes, userWishCount, nextResetTime等字段
// 用于显示愿望树统计信息和倒计时
binding = ActivityWishTreeBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());

View File

@ -3,43 +3,28 @@ package com.example.livestreaming.net;
import java.util.List;
import retrofit2.Call;
import java.util.Map;
import retrofit2.http.Body;
import retrofit2.http.DELETE;
import retrofit2.http.GET;
import retrofit2.http.Path;
import retrofit2.http.POST;
import retrofit2.http.Query;
public interface ApiService {
@POST("api/front/login")
Call<ApiResponse<LoginResponse>> login(@Body LoginRequest body);
@GET("api/front/live/public/rooms")
@POST("api/front/register")
Call<ApiResponse<LoginResponse>> register(@Body RegisterRequest body);
@GET("api/rooms")
Call<ApiResponse<List<Room>>> getRooms();
@POST("api/front/live/rooms")
@POST("api/rooms")
Call<ApiResponse<Room>> createRoom(@Body CreateRoomRequest body);
@GET("api/front/live/public/rooms/{id}")
@GET("api/rooms/{id}")
Call<ApiResponse<Room>> getRoom(@Path("id") String id);
@DELETE("api/front/live/rooms/{id}")
@DELETE("api/rooms/{id}")
Call<ApiResponse<Object>> deleteRoom(@Path("id") String id);
// 弹幕消息 API
@GET("api/front/live/public/rooms/{roomId}/messages")
Call<ApiResponse<List<ChatMessageResponse>>> getMessages(
@Path("roomId") String roomId,
@Query("limit") int limit);
@POST("api/front/live/public/rooms/{roomId}/messages")
Call<ApiResponse<ChatMessageResponse>> sendMessage(
@Path("roomId") String roomId,
@Body Map<String, String> body);
// 关注主播 API
@POST("api/front/live/follow")
Call<ApiResponse<Map<String, Object>>> followStreamer(@Body Map<String, Object> body);
}

View File

@ -0,0 +1,42 @@
package com.example.livestreaming.net;
import com.google.gson.annotations.SerializedName;
public class RegisterRequest {
@SerializedName("phone")
private final String phone;
@SerializedName("password")
private final String password;
@SerializedName("verificationCode")
private final String verificationCode;
@SerializedName("nickname")
private final String nickname;
public RegisterRequest(String phone, String password, String verificationCode, String nickname) {
this.phone = phone;
this.password = password;
this.verificationCode = verificationCode;
this.nickname = nickname;
}
public String getPhone() {
return phone;
}
public String getPassword() {
return password;
}
public String getVerificationCode() {
return verificationCode;
}
public String getNickname() {
return nickname;
}
}

View File

@ -27,6 +27,9 @@ public class Room {
@SerializedName("viewerCount")
private int viewerCount;
@SerializedName("coverUrl")
private String coverUrl;
@SerializedName("streamUrls")
private StreamUrls streamUrls;
@ -72,6 +75,10 @@ public class Room {
this.viewerCount = viewerCount;
}
public void setCoverUrl(String coverUrl) {
this.coverUrl = coverUrl;
}
public String getId() {
return id;
}
@ -104,6 +111,10 @@ public class Room {
return viewerCount;
}
public String getCoverUrl() {
return coverUrl;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
@ -116,11 +127,12 @@ public class Room {
&& Objects.equals(streamerName, room.streamerName)
&& Objects.equals(type, room.type)
&& Objects.equals(streamKey, room.streamKey)
&& Objects.equals(coverUrl, room.coverUrl)
&& Objects.equals(streamUrls, room.streamUrls);
}
@Override
public int hashCode() {
return Objects.hash(id, title, streamerName, type, streamKey, isLive, viewerCount, streamUrls);
return Objects.hash(id, title, streamerName, type, streamKey, isLive, viewerCount, coverUrl, streamUrls);
}
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#7A3FF2" />
<corners android:radius="20dp" />
</shape>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#6A2FE2" />
<corners android:radius="20dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#7A3FF2" />
<corners android:radius="20dp" />
</shape>
</item>
</selector>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#E53935" />
<corners android:radius="4dp" />
</shape>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@android:color/white" />
<corners android:radius="16dp" />
</shape>

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#0A000000" />
<corners android:radius="16dp" />
</shape>
</item>
<item
android:bottom="2dp"
android:left="0dp"
android:right="0dp"
android:top="0dp">
<shape android:shape="rectangle">
<solid android:color="@android:color/white" />
<corners android:radius="16dp" />
</shape>
</item>
</layer-list>

View File

@ -0,0 +1,201 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F6F7FB">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:paddingTop="48dp"
android:paddingBottom="48dp">
<!-- 返回按钮 -->
<ImageView
android:id="@+id/backButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="返回"
android:padding="8dp"
android:src="@drawable/ic_arrow_back_24"
android:scaleType="fitCenter"
android:clickable="true"
android:focusable="true"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- Logo或标题区域 -->
<TextView
android:id="@+id/appTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="直播应用"
android:textColor="#111111"
android:textSize="32sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginTop="16dp" />
<!-- 演示模式提示 -->
<TextView
android:id="@+id/demoModeHint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="提示:后端未接入时,将使用演示模式登录"
android:textColor="#FF9800"
android:textSize="12sp"
android:visibility="visible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appTitle" />
<TextView
android:id="@+id/welcomeText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="欢迎回来"
android:textColor="#666666"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/demoModeHint" />
<!-- 账号输入框 -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/accountLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:hint="手机号/账号"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/welcomeText"
app:boxBackgroundMode="outline"
app:boxCornerRadiusBottomEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusTopStart="12dp"
app:boxStrokeColor="#E0E0E0"
app:hintTextColor="#999999">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/accountInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1"
android:minHeight="56dp"
android:textColor="#111111"
android:textSize="16sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- 密码输入框 -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/passwordLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="密码"
app:endIconMode="password_toggle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountLayout"
app:boxBackgroundMode="outline"
app:boxCornerRadiusBottomEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusTopStart="12dp"
app:boxStrokeColor="#E0E0E0"
app:hintTextColor="#999999">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/passwordInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:maxLines="1"
android:minHeight="56dp"
android:textColor="#111111"
android:textSize="16sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- 忘记密码 -->
<TextView
android:id="@+id/forgotPasswordText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:clickable="true"
android:focusable="true"
android:padding="8dp"
android:text="忘记密码?"
android:textColor="#6200EE"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/passwordLayout" />
<!-- 登录按钮 -->
<com.google.android.material.button.MaterialButton
android:id="@+id/loginButton"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_marginTop="32dp"
android:backgroundTint="#6200EE"
android:text="登录"
android:textColor="@android:color/white"
android:textSize="16sp"
android:textStyle="bold"
app:cornerRadius="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/forgotPasswordText" />
<!-- 注册链接 -->
<TextView
android:id="@+id/registerLinkText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:clickable="true"
android:focusable="true"
android:padding="8dp"
android:text="还没有账号?立即注册"
android:textColor="#6200EE"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/loginButton" />
<!-- 加载提示 -->
<ProgressBar
android:id="@+id/loadingProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,261 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F6F7FB">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:paddingTop="48dp"
android:paddingBottom="48dp">
<!-- 返回按钮 -->
<ImageView
android:id="@+id/backButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="返回"
android:padding="8dp"
android:src="@drawable/ic_arrow_back_24"
android:scaleType="fitCenter"
android:clickable="true"
android:focusable="true"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- 标题 -->
<TextView
android:id="@+id/appTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="创建账号"
android:textColor="#111111"
android:textSize="32sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/backButton" />
<!-- 演示模式提示 -->
<TextView
android:id="@+id/demoModeHint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="提示:后端未接入时,将使用演示模式注册"
android:textColor="#FF9800"
android:textSize="12sp"
android:visibility="visible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appTitle" />
<TextView
android:id="@+id/welcomeText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="填写信息完成注册"
android:textColor="#666666"
android:textSize="16sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/demoModeHint" />
<!-- 手机号输入框 -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/phoneLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:hint="手机号"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/welcomeText"
app:boxBackgroundMode="outline"
app:boxCornerRadiusBottomEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusTopStart="12dp"
app:boxStrokeColor="#E0E0E0"
app:hintTextColor="#999999">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/phoneInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="phone"
android:maxLines="1"
android:minHeight="56dp"
android:textColor="#111111"
android:textSize="16sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- 验证码输入框 -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/verificationCodeLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="验证码"
app:layout_constraintEnd_toStartOf="@id/sendCodeButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/phoneLayout"
app:boxBackgroundMode="outline"
app:boxCornerRadiusBottomEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusTopStart="12dp"
app:boxStrokeColor="#E0E0E0"
app:hintTextColor="#999999">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/verificationCodeInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:maxLines="1"
android:minHeight="56dp"
android:textColor="#111111"
android:textSize="16sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- 发送验证码按钮 -->
<com.google.android.material.button.MaterialButton
android:id="@+id/sendCodeButton"
android:layout_width="wrap_content"
android:layout_height="56dp"
android:layout_marginStart="12dp"
android:backgroundTint="#6200EE"
android:text="发送验证码"
android:textColor="@android:color/white"
android:textSize="14sp"
app:cornerRadius="12dp"
app:layout_constraintBaseline_toBaselineOf="@id/verificationCodeLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/verificationCodeLayout" />
<!-- 密码输入框 -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/passwordLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="密码"
app:endIconMode="password_toggle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/verificationCodeLayout"
app:boxBackgroundMode="outline"
app:boxCornerRadiusBottomEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusTopStart="12dp"
app:boxStrokeColor="#E0E0E0"
app:hintTextColor="#999999">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/passwordInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:maxLines="1"
android:minHeight="56dp"
android:textColor="#111111"
android:textSize="16sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- 昵称输入框 -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/nicknameLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="昵称"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/passwordLayout"
app:boxBackgroundMode="outline"
app:boxCornerRadiusBottomEnd="12dp"
app:boxCornerRadiusBottomStart="12dp"
app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusTopStart="12dp"
app:boxStrokeColor="#E0E0E0"
app:hintTextColor="#999999">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/nicknameInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1"
android:minHeight="56dp"
android:textColor="#111111"
android:textSize="16sp" />
</com.google.android.material.textfield.TextInputLayout>
<!-- 注册按钮 -->
<com.google.android.material.button.MaterialButton
android:id="@+id/registerButton"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_marginTop="32dp"
android:backgroundTint="#6200EE"
android:text="注册"
android:textColor="@android:color/white"
android:textSize="16sp"
android:textStyle="bold"
app:cornerRadius="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/nicknameLayout" />
<!-- 登录链接 -->
<TextView
android:id="@+id/loginLinkText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:clickable="true"
android:focusable="true"
android:padding="8dp"
android:text="已有账号?立即登录"
android:textColor="#6200EE"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/registerButton" />
<!-- 加载提示 -->
<ProgressBar
android:id="@+id/loadingProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -1,78 +1,127 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="72dp"
android:background="@android:color/white"
android:paddingStart="16dp"
android:paddingEnd="16dp">
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:background="@drawable/bg_nearby_user_card_shadow"
android:elevation="2dp"
android:padding="16dp">
<ImageView
android:id="@+id/avatarImage"
android:layout_width="44dp"
android:layout_height="44dp"
android:background="@drawable/bg_avatar_circle"
android:padding="6dp"
android:scaleType="centerCrop"
android:src="@drawable/ic_account_circle_24"
app:layout_constraintBottom_toBottomOf="parent"
<!-- 头像容器,带直播状态指示 -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/avatarContainer"
android:layout_width="56dp"
android:layout_height="56dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<TextView
android:id="@+id/userName"
<ImageView
android:id="@+id/avatarImage"
android:layout_width="56dp"
android:layout_height="56dp"
android:background="@drawable/bg_avatar_circle"
android:padding="3dp"
android:scaleType="centerCrop"
android:src="@drawable/ic_account_circle_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- 直播状态徽章 -->
<TextView
android:id="@+id/liveBadge"
android:layout_width="wrap_content"
android:layout_height="18dp"
android:background="@drawable/bg_live_badge"
android:gravity="center"
android:paddingStart="6dp"
android:paddingEnd="6dp"
android:text="LIVE"
android:textColor="@android:color/white"
android:textSize="10sp"
android:textStyle="bold"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/avatarImage"
app:layout_constraintEnd_toEndOf="@id/avatarImage"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- 用户信息容器 -->
<LinearLayout
android:id="@+id/userInfoContainer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="12dp"
android:ellipsize="end"
android:maxLines="1"
android:text="用户昵称"
android:textColor="#111111"
android:textSize="15sp"
android:textStyle="bold"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="@id/avatarContainer"
app:layout_constraintEnd_toStartOf="@id/addButton"
app:layout_constraintStart_toEndOf="@id/avatarImage"
app:layout_constraintTop_toTopOf="@id/avatarImage" />
app:layout_constraintStart_toEndOf="@id/avatarContainer"
app:layout_constraintTop_toTopOf="@id/avatarContainer">
<TextView
android:id="@+id/distanceText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="12dp"
android:ellipsize="end"
android:maxLines="1"
android:text="距离 1.2km"
android:textColor="#666666"
android:textSize="13sp"
app:layout_constraintEnd_toStartOf="@id/addButton"
app:layout_constraintStart_toEndOf="@id/avatarImage"
app:layout_constraintTop_toBottomOf="@id/userName" />
<TextView
android:id="@+id/userName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="用户昵称"
android:textColor="#1A1A1A"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:id="@+id/locationIcon"
android:layout_width="14dp"
android:layout_height="14dp"
android:src="@android:drawable/ic_menu_mylocation"
android:tint="#999999"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/distanceText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:ellipsize="end"
android:maxLines="1"
android:text="距离 1.2km"
android:textColor="#666666"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
<!-- 添加按钮 -->
<TextView
android:id="@+id/addButton"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:background="@drawable/bg_purple_999"
android:layout_height="36dp"
android:background="@drawable/bg_add_button_nearby_pressed"
android:gravity="center"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:minWidth="72dp"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:text="添加"
android:textColor="@android:color/white"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="@id/avatarContainer"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/divider"
android:layout_width="0dp"
android:layout_height="1dp"
android:background="#0F000000"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/avatarImage" />
app:layout_constraintTop_toTopOf="@id/avatarContainer" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -41,7 +41,7 @@
## ✅ 已完善功能
### 1. 主界面 (MainActivity)
**完成度**: 90%
**完成度**: 95%
- ✅ 瀑布流布局展示直播间
- ✅ 下拉刷新
@ -50,16 +50,19 @@
- ✅ 推流地址显示和复制
- ✅ 搜索框UI
- ✅ 语音搜索功能
- ✅ 分类标签UI
- ✅ 分类标签UI和筛选功能CategoryFilterManager
- ✅ 顶部标签页功能(关注/发现/附近)
- ✅ 底部导航栏
- ✅ 侧边栏菜单
- ✅ 空状态和错误处理
**待完善**:
- ⚠️ 分类筛选功能UI已准备好但筛选逻辑使用演示数据
- ⚠️ 顶部标签页(关注/发现/附近)跳转到占位页面
- ⚠️ 分类筛选使用本地数据筛选等待后端API
- ⚠️ 顶部标签页数据使用演示数据(关注列表、发现推荐、附近用户)
- ⚠️ 附近用户功能需要位置权限,数据为模拟数据
### 2. 直播间详情 (RoomDetailActivity)
**完成度**: 85%
**完成度**: 90%
- ✅ 直播流播放HLS/FLV
- ✅ 全屏播放
@ -69,12 +72,13 @@
- ✅ 观看人数显示
- ✅ 自动重连机制
- ✅ 低延迟播放配置
- ✅ 分享功能(系统分享菜单)
**待完善**:
- ⚠️ 聊天功能使用模拟数据未连接真实WebSocket
- ⚠️ 礼物打赏功能未实现
- ⚠️ 分享功能未实现
- ⚠️ 弹幕功能未实现
- ⚠️ 分享链接为模拟数据(等待后端生成真实分享链接)
### 3. 个人资料 (ProfileActivity)
**完成度**: 90%
@ -86,11 +90,13 @@
- ✅ 作品/收藏/赞过标签页
- ✅ 关注/粉丝/获赞统计
- ✅ 主页链接复制
- ✅ 作品列表基础UIUserWorksAdapter
**待完善**:
- ⚠️ 作品发布功能(显示"待接入"
- ⚠️ 头像上传功能(仅支持本地资源)
- ⚠️ 作品列表使用演示数据
- ⚠️ 作品列表使用演示数据Integer列表
- ⚠️ 作品详情页面未实现
### 4. 编辑资料 (EditProfileActivity)
**完成度**: 95%
@ -108,45 +114,53 @@
- ⚠️ 数据同步到后端API
### 5. 消息功能 (MessagesActivity)
**完成度**: 80%
**完成度**: 85%
- ✅ 会话列表展示
- ✅ 未读消息徽章
- ✅ 滑动删除/标记已读
- ✅ 会话详情页面
- ✅ 消息发送(本地)
- ✅ 会话搜索功能(按会话标题和消息内容搜索)
- ✅ 空状态显示
**待完善**:
- ⚠️ 所有消息数据使用演示数据
- ⚠️ 未连接真实消息服务器
- ⚠️ 未连接真实消息服务器WebSocket
- ⚠️ 消息推送功能未实现
- ⚠️ 图片/语音消息未实现
### 6. 会话详情 (ConversationActivity)
**完成度**: 75%
**完成度**: 90%
- ✅ 消息列表展示
- ✅ 消息发送
- ✅ 自动滚动到底部
- ✅ 自动滚动到底部使用smoothScrollToPosition
- ✅ 未读消息标记
- ✅ 消息状态显示(发送中、已发送、已读)
- ✅ 消息长按菜单(复制、删除)
- ✅ 内存泄漏防护onDestroy中清理Handler
- ✅ DiffUtil使用messageId优化性能
**待完善**:
- ⚠️ 消息数据使用演示数据
- ⚠️ 未实现实时消息接收
- ⚠️ 消息类型单一(仅文本)
- ⚠️ 未实现实时消息接收WebSocket
- ⚠️ 消息类型单一(仅文本,图片/语音消息未实现
### 7. 搜索功能 (SearchActivity)
**完成度**: 60%
**完成度**: 70%
- ✅ 搜索界面UI
- ✅ 实时搜索过滤
- ✅ 搜索结果展示
- ✅ 空状态显示(无搜索结果时显示提示)
**待完善**:
- ⚠️ 仅支持本地数据过滤
- ⚠️ 未连接后端搜索API
- ⚠️ 搜索历史未实现
- ⚠️ 热门搜索未实现
- ⚠️ 搜索建议(自动补全)未实现
### 8. 缘池功能 (FishPondActivity)
**完成度**: 85%
@ -174,42 +188,47 @@
### 10. 其他已实现页面
- ✅ 观看历史 (WatchHistoryActivity) - 基础UI
- ✅ 我的好友 (MyFriendsActivity) - 基础UI
- ✅ 我的好友 (MyFriendsActivity) - 基础UI,支持空状态
- ✅ 粉丝列表 (FansListActivity) - 基础UI
- ✅ 关注列表 (FollowingListActivity) - 基础UI
- ✅ 获赞列表 (LikesListActivity) - 基础UI
- ✅ 设置页面 (SettingsPageActivity) - 功能已完善
- ✅ 用户资料(只读)(UserProfileReadOnlyActivity) - 完整实现
- ✅ 登录页面 (LoginActivity) - 完整实现
- ✅ 注册页面 (RegisterActivity) - 完整实现
- ✅ 通知列表 (NotificationsActivity) - 完整实现,支持分类筛选
- ✅ 通知设置 (NotificationSettingsActivity) - 完整实现
---
## ⚠️ 部分完善功能
### 1. 网络层集成
**完成度**: 60%
**完成度**: 70%
- ✅ Retrofit + OkHttp 配置完成
- ✅ API 接口定义完成
- ✅ 基础错误处理
- ✅ 基础错误处理ErrorHandler工具类
- ✅ 模拟器/真机网络地址配置
- ✅ 网络请求生命周期管理NetworkRequestManager
- ✅ 统一错误提示和重试机制
**待完善**:
- ⚠️ API 调用失败时大量使用演示数据
- ⚠️ 缺少统一的错误处理机制
- ⚠️ 缺少请求重试机制
- ⚠️ 缺少网络状态监听
- ⚠️ 缺少请求缓存策略
### 2. 数据持久化
**完成度**: 50%
### 2. 数据存储(前端)
**完成度**: 60%
- ✅ SharedPreferences 用于个人资料
- ✅ 本地数据缓存
- ✅ SharedPreferences 用于个人资料和配置
- ✅ 本地数据缓存(内存缓存)
- ✅ 分类筛选条件记忆SharedPreferences
**待完善**:
- ⚠️ 未使用 Room 数据库
- ⚠️ 未实现离线数据支持
- ⚠️ 未实现数据同步机制
- ⚠️ 未实现离线数据支持(当前仅使用内存缓存)
- ⚠️ 未实现数据同步机制(等待后端)
### 3. 图片处理
**完成度**: 70%
@ -230,12 +249,12 @@
### 1. 后端API集成
**问题**: 虽然定义了API接口但很多功能在API失败时回退到演示数据
**需要完善**:
- [ ] 完善所有API接口的错误处理
- [ ] 实现API请求的加载状态管理
- [ ] 实现API数据的本地缓存
- [ ] 实现数据同步机制
- [ ] 添加API版本管理
**需要完善**(前端部分):
- [ ] 完善所有API接口的错误处理(前端错误提示)
- [ ] 实现API请求的加载状态管理前端UI
- [ ] 添加API版本管理前端配置
**注意**: API数据的本地缓存和数据同步机制需要等待后端支持前端仅负责UI展示和错误处理。
### 2. 实时通信
**问题**: 聊天、弹幕等功能使用模拟数据
@ -276,20 +295,30 @@
- [ ] 添加位置权限处理
### 6. 占位页面功能
**以下功能跳转到 `TabPlaceholderActivity`,需要实现**:
**状态**: 部分功能已实现基础UI但核心功能未实现
- [ ] 语音匹配 (VoiceMatchActivity)
- [ ] 心动信号 (HeartbeatSignalActivity)
- [ ] 在线处对象 (OnlineDatingActivity)
- [ ] 找人玩游戏 (FindGameActivity)
- [ ] 一起KTV (KTVTogetherActivity)
- [ ] 你画我猜 (DrawGuessActivity)
- [ ] 和平精英 (PeaceEliteActivity)
- [ ] 桌子游 (TableGamesActivity)
- [ ] 公园勋章
- [ ] 分享主页(部分实现,仅复制链接)
- [ ] 加好友
- [ ] 定位/发现
**已实现基础UI的Activity**:
- ✅ 语音匹配 (VoiceMatchActivity) - 基础页面
- ✅ 心动信号 (HeartbeatSignalActivity) - 基础页面
- ✅ 在线处对象 (OnlineDatingActivity) - 基础页面
- ✅ 找人玩游戏 (FindGameActivity) - 基础页面
- ✅ 一起KTV (KTVTogetherActivity) - 基础页面
- ✅ 你画我猜 (DrawGuessActivity) - 基础页面
- ✅ 和平精英 (PeaceEliteActivity) - 基础页面
- ✅ 桌子游 (TableGamesActivity) - 基础页面
**TabPlaceholderActivity中的占位功能**:
- ⚠️ 公园勋章 - 显示占位页面
- ⚠️ 加好友 - 显示附近用户列表(演示数据)
- ⚠️ 定位/发现 - 显示占位页面
- ⚠️ 关注页面TabPlaceholderActivity中 - 显示关注列表(演示数据)
- ⚠️ 附近页面TabPlaceholderActivity中 - 显示附近用户(演示数据)
- ⚠️ 发现页面TabPlaceholderActivity中 - 显示推荐内容(演示数据)
**需要完善**:
- [ ] 实现各功能的核心逻辑
- [ ] 接入后端API获取真实数据
- [ ] 实现游戏匹配、语音匹配等功能
### 7. 作品功能
**问题**: 个人资料中的"作品"标签页未实现
@ -384,11 +413,12 @@
- [ ] 引入依赖注入框架Hilt/Dagger
- [ ] 模块化拆分
### 2. 数据层优化
- [ ] 引入 Room 数据库
- [ ] 实现 Repository 模式
- [ ] 添加数据同步机制
- [ ] 实现离线数据支持
### 2. 数据层优化(前端)
- [ ] 实现 Repository 模式(本地数据源)
- [ ] 优化内存缓存策略
- [ ] 实现请求去重机制
**注意**: Room数据库和离线数据支持暂不实现当前使用SharedPreferences和内存缓存。
### 3. UI/UX 优化
- [ ] 统一设计语言和组件库
@ -439,48 +469,29 @@
- [x] 修复动画未取消问题
- [x] 完善网络请求取消机制
- [x] 优化播放器资源释放
- [x] 添加 LeakCanary 检测
**实际工作量**: 1.5天
**完成内容**:
- 为所有使用Handler的Activity添加onDestroy方法清理Runnable防止内存泄漏
- 完善动画生命周期管理确保动画在Activity销毁时正确取消
- 创建NetworkRequestManager和NetworkUtils实现生命周期感知的网络请求管理
- 创建`NetworkRequestManager`工具类实现生命周期感知的网络请求管理在Activity销毁时自动取消所有请求
- 验证ExoPlayer资源释放逻辑正确已有实现
- 集成LeakCanary内存泄漏检测工具
#### 3. **统一加载状态** ⭐⭐ ✅ 已完成
**为什么重要**: 提升用户体验一致性
- [x] 创建统一的加载状态组件
- [x] 创建统一的加载状态管理器
- [x] 实现骨架屏Skeleton Screen
- [x] 统一所有页面的加载提示
- [x] 添加加载动画
**完成内容**:
- 创建了 `LoadingView` 组件,提供统一的加载状态显示(支持自定义提示文字)
- 实现了 `SkeletonView``SkeletonRoomAdapter`,支持骨架屏占位(带闪烁动画效果)
- 创建了 `LoadingStateManager` 工具类统一管理加载状态支持LoadingView、ProgressBar、骨架屏
- 更新了所有主要Activity使用统一的加载状态
- **MainActivity**: 在列表为空时使用骨架屏替代简单的LoadingView
- **RoomDetailActivity**: 使用LoadingView显示加载状态
- **SearchActivity**: 搜索时显示"搜索中..."加载状态
- 创建了 `LoadingStateManager` 工具类,统一管理加载状态(支持骨架屏、下拉刷新等)
- 实现了骨架屏适配器(`SkeletonAdapter`在RecyclerView中显示占位效果
- 更新了主要Activity使用统一的加载状态
- **MainActivity**: 在列表为空时使用骨架屏显示加载状态
- **SearchActivity**: 搜索时显示加载状态
- **MessagesActivity**: 加载消息列表时显示加载状态
- 添加了加载动画资源(骨架屏闪烁效果、加载提示文字)
- 所有加载状态都通过 `LoadingStateManager` 统一管理,确保一致性
#### 4. **数据持久化(本地)** ⭐⭐⭐
**为什么重要**: 支持离线使用,提升用户体验
- [ ] 引入 Room 数据库
- [ ] 缓存直播间列表到本地
- [ ] 缓存用户信息
- [ ] 实现搜索历史存储
- [ ] 实现观看历史存储
- [ ] 实现消息记录缓存
**预计工作量**: 3-5天
---
### 🟡 第二优先级(功能完善与架构优化)
@ -501,7 +512,7 @@
- [x] 完善本地数据筛选逻辑
- [x] 实现筛选条件记忆SharedPreferences
- [x] 优化筛选性能
- [x] 优化筛选性能(异步筛选)
- [x] 添加筛选动画效果
**完成内容**:
@ -511,55 +522,88 @@
- 添加了平滑的过渡动画效果(淡入淡出 + RecyclerView ItemAnimator
- 优化了筛选算法优先使用房间的type字段降级到演示数据分类算法
- 在Activity恢复时自动恢复上次选中的分类标签
- 支持在关注、发现、附近三个标签页中分别筛选
**预计工作量**: 1-2天
**待完善**:
- ⚠️ 等待后端API支持按分类获取房间列表
#### 7. **顶部标签页功能(前端实现)** ⭐⭐
**预计工作量**: 1-2天已完成
#### 7. **顶部标签页功能(前端实现)** ⭐⭐ ✅ 已完成
**为什么重要**: 完善主界面功能
- [ ] 实现关注页面(使用本地数据)
- [ ] 实现发现页面(推荐算法前端实现)
- [ ] 实现附近页面(使用模拟位置数据)
- [ ] 添加位置权限申请(即使后端未就绪)
- [x] 实现关注页面(使用本地数据)
- [x] 实现发现页面(推荐算法前端实现)
- [x] 实现附近页面(使用模拟位置数据)
- [x] 添加位置权限申请(即使后端未就绪)
**预计工作量**: 3-4天
**完成内容**:
- 实现了 `showFollowTab()`、`showDiscoverTab()`、`showNearbyTab()` 三个方法
- 创建了 `NearbyUser` 数据模型和 `NearbyUsersAdapter` 适配器
- 创建了 `LocationDataManager` 位置数据管理器
- 实现了位置权限申请和权限处理逻辑
- 关注页面显示已关注主播的直播列表
- 发现页面显示推荐内容,支持分类筛选
- 附近页面显示附近用户列表,支持空状态显示
- 所有页面都支持搜索功能(在对应数据源中搜索)
#### 8. **搜索功能增强**
**待完善**:
- ⚠️ 数据使用演示数据等待后端API
- ⚠️ 附近用户数据为模拟数据,需要真实位置服务
**预计工作量**: 3-4天已完成
#### 8. **搜索功能增强** ⭐ ⚠️ 部分完成
**为什么重要**: 提升搜索体验
- [x] 实时搜索过滤SearchActivity已实现
- [x] 空状态显示SearchActivity已实现
- [ ] 实现搜索历史(本地存储)
- [ ] 实现热门搜索(模拟数据)
- [ ] 优化搜索性能
- [ ] 添加搜索建议
- [ ] 优化搜索性能(防抖、缓存)
- [ ] 添加搜索建议(自动补全)
**预计工作量**: 2-3天
**已完成内容**:
- ✅ SearchActivity已实现实时搜索过滤功能
- ✅ SearchActivity已实现空状态显示无搜索结果时显示提示
- ✅ 支持从MainActivity传递搜索关键词到SearchActivity
#### 9. **消息功能完善(前端)** ⭐⭐ ⚠️ 待实现
**待实现内容**:
- ⚠️ 搜索历史功能使用SharedPreferences存储最近搜索记录
- ⚠️ 热门搜索功能(显示热门搜索关键词)
- ⚠️ 搜索性能优化(防抖处理,避免频繁过滤)
- ⚠️ 搜索建议功能(输入时显示自动补全建议)
**预计工作量**: 2-3天部分完成剩余1-2天
#### 9. **消息功能完善(前端)** ⭐⭐ ✅ 已完成
**为什么重要**: 完善核心社交功能
- [x] 完善消息列表UI会话列表
- [ ] 实现消息状态显示(发送中、已发送、已读)
- [ ] 优化消息列表性能使用messageId
- [ ] 添加消息操作(复制、删除等)
- [ ] 实现消息搜索(本地)
- [x] 实现消息状态显示(发送中、已发送、已读)
- [x] 优化消息列表性能使用messageId
- [x] 添加消息操作(复制、删除等)
- [x] 实现消息搜索(本地)
**已完成内容**:
- ✅ 会话列表展示MessagesActivity
- ✅ 滑动删除/标记已读功能MessagesActivity
- ✅ 消息发送和列表展示ConversationActivity
- ✅ 未读消息徽章管理
- ✅ ChatMessage已添加MessageStatus枚举SENDING, SENT, READ
- ✅ 在发送的消息气泡旁显示状态图标(时钟、单勾、双勾)- ConversationMessagesAdapter已实现
- ✅ 消息长按菜单,支持复制和删除操作 - ConversationActivity已实现
- ✅ DiffUtil已使用messageId作为唯一标识提升列表更新性能 - ConversationMessagesAdapter已优化
- ✅ MessagesActivity已实现搜索功能支持按会话标题和消息内容搜索
- ✅ 内存泄漏防护已实现onDestroy中清理Handler延迟任务- ConversationActivity已实现
- ✅ 滚动行为已优化使用smoothScrollToPosition- ConversationActivity已实现
**待实现内容**:
- ⚠️ 为ChatMessage添加MessageStatus枚举发送中、已发送、已读
- ⚠️ 在发送的消息气泡旁显示状态图标(时钟、单勾、双勾)
- ⚠️ 实现消息长按菜单,支持复制和删除操作
- ⚠️ 优化DiffUtil使用messageId作为唯一标识提升列表更新性能当前使用timestamp
- ⚠️ 在MessagesActivity中添加搜索功能支持按会话标题和消息内容搜索
- ⚠️ 优化消息列表UI包括消息预览处理图片/语音消息)、时间显示、布局优化
- ⚠️ 添加内存泄漏防护onDestroy中清理延迟任务
- ⚠️ 优化滚动行为使用smoothScrollToPosition替代scrollToPosition
**待完善内容**:
- ⚠️ 消息预览功能(处理图片/语音消息类型)- 当前仅支持文本消息
- ⚠️ 图片/语音消息发送和接收功能
- ⚠️ 实时消息接收WebSocket集成等待后端
**预计工作量**: 3-4天
**预计工作量**: 3-4天已完成
---
@ -609,15 +653,30 @@
### 🔵 第四优先级(功能扩展)
#### 14. **作品功能前端UI**
#### 14. **作品功能前端UI** ⚠️ 部分完成
**为什么重要**: 完善个人中心功能
- [ ] 实现作品列表UI
- [x] 作品列表UIProfileActivity已实现基础UI
- [x] UserWorksAdapter已创建使用演示数据
- [ ] 实现作品发布UI数据仅本地存储
- [ ] 实现作品详情页面
- [ ] 实现作品编辑UI
- [ ] 作品数据模型WorkItem
**预计工作量**: 3-4天
**已完成内容**:
- ✅ ProfileActivity中已实现作品标签页UI
- ✅ UserWorksAdapter已创建支持显示作品列表
- ✅ 作品列表使用GridLayoutManager3列布局
- ✅ 支持作品/收藏/赞过三个标签页切换
**待实现内容**:
- ⚠️ 作品发布功能(当前显示"待接入"提示)
- ⚠️ 作品详情页面(点击作品查看详情)
- ⚠️ 作品编辑和删除功能
- ⚠️ 作品数据模型WorkItem当前使用Integer列表drawable资源ID作为临时方案
- ⚠️ 作品数据持久化本地存储等待后端API
**预计工作量**: 3-4天部分完成剩余2-3天
#### 15. **分享功能(系统分享)** ⭐ ✅ 已完成
**为什么重要**: 提升传播能力
@ -698,17 +757,6 @@
- [ ] 重构重复代码
**预计工作量**: 持续进行
#### 20. **测试**
**为什么重要**: 保证代码质量
- [ ] 编写单元测试(工具类)
- [ ] 编写 UI 测试(关键流程)
- [ ] 性能测试
- [ ] 兼容性测试
**预计工作量**: 持续进行
---
### 📋 暂不实现(需等待后端)
@ -722,22 +770,23 @@
- ❌ 支付功能等待后端和支付SDK
- ❌ 推流功能如需要等待推流SDK集成
**注意**: 数据持久化Room数据库相关功能暂不实现当前使用SharedPreferences存储简单配置数据。
---
## 📅 开发时间估算
### 第一阶段(核心修复)- 约 8-13
### 第一阶段(核心修复)- 约 5-8 天
- 空状态和错误处理
- 生命周期和内存管理
- 统一加载状态
- 数据持久化
### 第二阶段(架构优化)- 约 12-18
### 第二阶段(架构优化)- 约 8-12 天
- 前端架构优化
- 分类筛选功能
- 顶部标签页功能
- 搜索功能增强
- 消息功能完善
- ~~分类筛选功能~~ ✅ 已完成
- ~~顶部标签页功能~~ ✅ 已完成
- 搜索功能增强(部分完成,剩余搜索历史和热门搜索)
- ~~消息功能完善~~ ✅ 已完成
### 第三阶段(体验增强)- 约 9-13 天
- 引导页面
@ -745,18 +794,20 @@
- 深色模式
- 多屏幕适配
### 第四阶段(功能扩展)- 约 9-13
### 第四阶段(功能扩展)- 约 3-6
- 作品功能
- 分享功能
- 通知功能
- 设置页面完善
- ~~分享功能~~ ✅ 已完成
- ~~通知功能~~ ✅ 已完成
- ~~设置页面完善~~ ✅ 已完成
### 第五阶段(优化提升)- 持续进行
- 性能优化
- 代码质量
- 测试
**总计**: 约 38-57 个工作日(约 2-3 个月,按单人开发估算)
**总计**: 约 25-39 个工作日(约 1.5-2 个月,按单人开发估算)
**注意**: 部分功能已完成(分类筛选、顶部标签页、分享、通知、设置、消息功能完善),实际开发时间会相应减少。当前已完成约 60-70% 的前端功能开发。
---
@ -765,9 +816,9 @@
如果想快速看到效果,建议按以下顺序:
1. **第1周**: 空状态 + 错误处理 + 内存泄漏修复
2. **第2周**: 统一加载状态 + 数据持久化Room
3. **第3周**: 架构优化MVVM+ 分类筛选
4. **第4周**: 顶部标签页 + 搜索增强
2. **第2周**: 统一加载状态 + 分类筛选
3. **第3周**: 架构优化MVVM+ 顶部标签页
4. **第4周**: 搜索增强 + 消息功能完善
这样可以在一个月内完成核心功能的前端完善。
@ -776,18 +827,20 @@
## 📝 总结(前端开发视角)
### 当前状态
- **整体完成度**: 约 60-70%前端UI层面
- **核心UI功能**: 基本完成,但缺少完善
- **用户体验**: 良好,但需要优化细节
- **代码质量**: 中等,需要重构和优化
- **数据层**: 仅使用 SharedPreferences需要引入 Room
- **整体完成度**: 约 80-85%前端UI层面
- **核心UI功能**: 基本完成,大部分功能已实现前端逻辑
- **用户体验**: 良好,已实现空状态、错误处理、加载状态等
- **代码质量**: 中等,已实现部分工具类和统一管理
- **数据层**: 使用 SharedPreferences 存储配置,内存缓存存储临时数据
- **已完成功能**: 主界面(含顶部标签页、分类筛选)、直播间详情、个人资料、消息(含状态显示、长按菜单、搜索)、搜索(含实时过滤、空状态)、分享、通知、设置等
- **最近完成**: 消息功能完善(状态显示、长按菜单、性能优化、搜索功能)
### 前端可独立完成的工作
1. ✅ **UI/UX 完善**: 空状态、错误处理、加载状态
2. ✅ **架构优化**: MVVM、Repository 模式、代码重构
3. ✅ **数据持久化**: Room 数据库、本地缓存
4. ✅ **功能完善**: 分类筛选、搜索增强、消息功能
5. ✅ **体验增强**: 引导页、动画、深色模式
3. ✅ **功能完善**: 分类筛选、搜索增强、消息功能
4. ✅ **体验增强**: 引导页、动画、深色模式
5. ✅ **工具类**: 分享功能、通知功能、设置页面、缓存管理
### 需要等待后端的功能
1. ❌ **API 集成**: 等待后端接口定义
@ -798,9 +851,9 @@
### 前端开发建议
1. **立即开始**: 空状态处理、内存泄漏修复、统一加载状态
2. **第一周**: 完成基础架构和用户体验修复
3. **第二周**: 引入 Room 数据库,实现本地数据持久化
4. **第三周**: 架构优化,引入 MVVM 模式
5. **第四周**: 功能完善,实现分类筛选、搜索增强
3. **第二周**: 架构优化,引入 MVVM 模式
4. **第三周**: 功能完善,实现分类筛选、搜索增强等
5. **第四周**: 体验增强,实现引导页、动画优化
### 开发原则
- ✅ **优先前端可独立完成的工作**
@ -808,6 +861,7 @@
- ✅ **保持代码结构便于后续对接后端**
- ✅ **注重用户体验和代码质量**
- ⏸️ **等待后端接口的功能先做UI数据用模拟**
- ⏸️ **暂不实现数据持久化Room数据库使用SharedPreferences存储简单配置**
---

View File

@ -0,0 +1,349 @@
# Android 直播应用 - 项目进度汇报
> **汇报时间**: 2024年
> **项目名称**: Android 直播应用
> **开发阶段**: 前端功能完善阶段
---
## 📊 项目整体进度
### 完成度概览
- **整体完成度**: **80-85%**前端UI层面
- **核心功能完成度**: **85-90%**
- **代码质量**: 中等,已实现统一工具类和错误处理机制
### 主要成果
**已完成核心功能模块**: 主界面、直播间、个人中心、消息、搜索、分享、通知、设置等
**已完成基础架构**: 空状态处理、错误处理、加载状态管理、内存泄漏防护
**已完成用户体验优化**: 统一UI组件、搜索功能、消息状态显示等
---
## ✅ 本周/近期完成的工作
### 1. 消息功能完善 ⭐⭐⭐
**状态**: ✅ 已完成
**完成内容**:
- ✅ 实现消息状态显示(发送中、已发送、已读)
- 在消息气泡旁显示状态图标(时钟、单勾、双勾)
- 支持状态自动更新(发送中 → 已发送 → 已读)
- ✅ 实现消息长按菜单
- 支持复制消息内容
- 支持删除消息(带确认对话框)
- ✅ 优化消息列表性能
- DiffUtil使用messageId作为唯一标识提升列表更新性能
- 使用smoothScrollToPosition优化滚动体验
- ✅ 实现会话搜索功能
- 支持按会话标题搜索
- 支持按消息内容搜索
- 实时过滤,响应迅速
- ✅ 内存泄漏防护
- 在onDestroy中清理Handler延迟任务
- 确保Activity销毁时资源正确释放
**技术亮点**:
- 使用ListAdapter + DiffUtil实现高效的列表更新
- 消息状态通过Handler模拟真实发送流程
- 搜索功能支持实时过滤,用户体验流畅
---
### 2. 搜索功能增强 ⭐⭐
**状态**: ⚠️ 部分完成70%
**完成内容**:
- ✅ 实时搜索过滤功能
- 输入关键词即时显示搜索结果
- 支持按房间标题和主播名称搜索
- ✅ 空状态显示
- 无搜索结果时显示友好提示
- 支持从MainActivity传递搜索关键词
**待完善**:
- ⚠️ 搜索历史功能预计1天
- ⚠️ 热门搜索功能预计1天
- ⚠️ 搜索建议/自动补全预计1天
---
### 3. 作品功能基础搭建 ⭐
**状态**: ⚠️ 部分完成30%
**完成内容**:
- ✅ 作品列表UI已实现
- ProfileActivity中作品标签页UI完整
- UserWorksAdapter已创建
- 支持3列网格布局展示
**待完善**:
- ⚠️ 作品发布功能预计2天
- ⚠️ 作品详情页面预计1天
- ⚠️ 作品数据模型完善预计1天
---
## 📈 已完成的核心功能模块
### 1. 主界面 (MainActivity) - 95%
- ✅ 瀑布流布局展示直播间
- ✅ 下拉刷新和自动轮询15秒间隔
- ✅ 创建直播间功能
- ✅ 搜索框和语音搜索
- ✅ 分类标签筛选CategoryFilterManager
- ✅ 顶部标签页(关注/发现/附近)
- ✅ 底部导航栏和侧边栏菜单
### 2. 直播间详情 (RoomDetailActivity) - 90%
- ✅ 直播流播放HLS/FLV
- ✅ 全屏播放和横竖屏切换
- ✅ 聊天功能(模拟)
- ✅ 关注按钮和观看人数显示
- ✅ 自动重连机制
- ✅ 分享功能
### 3. 个人资料 (ProfileActivity) - 90%
- ✅ 个人资料展示和编辑
- ✅ 头像查看(大图预览)
- ✅ 标签显示(地区、性别、年龄、星座)
- ✅ 作品/收藏/赞过标签页
- ✅ 关注/粉丝/获赞统计
- ✅ 主页链接复制
### 4. 消息功能 (MessagesActivity + ConversationActivity) - 90%
- ✅ 会话列表展示
- ✅ 未读消息徽章管理
- ✅ 滑动删除/标记已读
- ✅ 消息发送和状态显示
- ✅ 消息长按菜单(复制、删除)
- ✅ 会话搜索功能
- ✅ 内存泄漏防护
### 5. 搜索功能 (SearchActivity) - 70%
- ✅ 搜索界面UI
- ✅ 实时搜索过滤
- ✅ 搜索结果展示
- ✅ 空状态显示
### 6. 其他已完成功能
- ✅ 分享功能ShareUtils工具类
- ✅ 通知功能NotificationsActivity + NotificationSettingsActivity
- ✅ 设置页面SettingsPageActivity
- ✅ 登录/注册页面
- ✅ 用户资料查看UserProfileReadOnlyActivity
---
## 🛠️ 技术架构与工具类
### 已实现的工具类
1. **ErrorHandler** - 统一错误处理和提示
2. **EmptyStateView** - 统一空状态组件
3. **LoadingStateManager** - 统一加载状态管理
4. **NetworkRequestManager** - 生命周期感知的网络请求管理
5. **CategoryFilterManager** - 分类筛选管理
6. **LocationDataManager** - 位置数据管理
7. **ShareUtils** - 分享功能工具类
8. **LocalNotificationManager** - 本地通知管理
9. **CacheManager** - 缓存管理
10. **UnreadMessageManager** - 未读消息管理
### 数据存储
- ✅ SharedPreferences - 用于个人资料和配置存储
- ✅ 内存缓存 - 用于临时数据缓存
- ✅ 网络层 - Retrofit + OkHttp 已配置完成
---
## ⚠️ 待完善功能(优先级排序)
### 🔴 高优先级1-2周内完成
#### 1. 搜索功能完善
- [ ] 搜索历史(本地存储)
- [ ] 热门搜索(模拟数据)
- [ ] 搜索建议(自动补全)
- **预计工作量**: 2-3天
#### 2. 作品功能完善
- [ ] 作品发布UI
- [ ] 作品详情页面
- [ ] 作品数据模型
- **预计工作量**: 3-4天
#### 3. 前端架构优化
- [ ] 引入 MVVM 架构ViewModel + LiveData
- [ ] 实现 Repository 模式
- [ ] 提取公共基类 Activity
- **预计工作量**: 5-7天
---
### 🟡 中优先级2-4周内完成
#### 4. 用户体验增强
- [ ] 引导页面和帮助
- [ ] 过渡动画和交互优化
- [ ] 深色模式支持
- [ ] 多屏幕适配
- **预计工作量**: 9-13天
---
### 🟢 低优先级(持续优化)
#### 5. 性能优化
- [ ] 图片加载优化Glide配置
- [ ] 列表滚动性能优化
- [ ] 请求去重机制
- [ ] 内存优化
- **预计工作量**: 3-4天
#### 6. 代码质量提升
- [ ] 提取硬编码字符串到资源文件
- [ ] 添加代码注释
- [ ] 统一代码风格
- [ ] 重构重复代码
- **预计工作量**: 持续进行
---
## 📋 等待后端支持的功能
以下功能需要后端API支持建议后端开发完成后再实现
- ❌ 后端API完整集成等待后端接口
- ❌ 实时通信WebSocket等待后端
- ❌ 真实数据同步(等待后端)
- ❌ 用户登录/注册(等待后端)
- ❌ 支付功能等待后端和支付SDK
- ❌ 推流功能如需要等待推流SDK集成
**当前策略**: 使用演示数据模拟后端接口,保持代码结构便于后续对接。
---
## 📅 下一步工作计划
### 本周计划(优先级排序)
1. **搜索功能完善**2-3天
- 实现搜索历史SharedPreferences存储
- 实现热门搜索(模拟数据)
- 添加搜索建议功能
2. **作品功能完善**3-4天
- 实现作品发布UI
- 实现作品详情页面
- 完善作品数据模型
3. **代码优化**(持续)
- 修复已知问题
- 优化代码结构
- 添加必要注释
### 下周计划
1. **前端架构优化**5-7天
- 引入 MVVM 架构
- 实现 Repository 模式
- 提取公共基类
2. **用户体验增强**(开始)
- 引导页面
- 过渡动画优化
---
## 📊 统计数据
### 代码统计
- **Activity数量**: 30+ 个
- **布局文件**: 50+ 个
- **工具类**: 10+ 个
- **适配器**: 15+ 个
### 功能统计
- **已完成功能模块**: 8+ 个核心模块
- **已完成工具类**: 10+ 个
- **已完成页面**: 25+ 个页面
### 完成度统计
- **整体完成度**: 80-85%
- **核心功能完成度**: 85-90%
- **UI完成度**: 90%+
- **功能完成度**: 75-80%
---
## 🎯 项目亮点
### 1. 完善的用户体验
- ✅ 统一的空状态处理
- ✅ 统一的错误提示机制
- ✅ 统一的加载状态管理
- ✅ 友好的用户交互反馈
### 2. 良好的代码架构
- ✅ 工具类统一管理
- ✅ 生命周期感知的网络请求
- ✅ 内存泄漏防护
- ✅ 性能优化DiffUtil、smoothScroll等
### 3. 完整的功能实现
- ✅ 消息功能完善(状态显示、长按菜单、搜索)
- ✅ 搜索功能基础实现
- ✅ 分享、通知、设置等辅助功能
### 4. 可扩展性
- ✅ 代码结构便于对接后端API
- ✅ 使用演示数据模拟,便于测试
- ✅ 预留接口便于后续扩展
---
## ⚠️ 风险与挑战
### 1. 后端依赖
- **风险**: 部分功能需要等待后端API支持
- **应对**: 使用演示数据模拟,保持代码结构便于对接
### 2. 数据持久化
- **风险**: 当前仅使用SharedPreferences和内存缓存
- **应对**: 等待后端API后实现完整的数据同步机制
### 3. 实时通信
- **风险**: WebSocket功能需要后端支持
- **应对**: 当前使用模拟数据等待后端WebSocket服务
---
## 💡 总结
### 当前状态
项目前端开发进展顺利,**整体完成度达到80-85%**。核心功能模块基本完成,用户体验良好,代码质量持续提升。
### 主要成就
1. ✅ **消息功能完善** - 实现了完整的消息状态显示、长按菜单、搜索等功能
2. ✅ **搜索功能基础** - 实现了实时搜索过滤和空状态显示
3. ✅ **工具类体系** - 建立了完善的工具类体系,提升代码复用性
4. ✅ **用户体验** - 统一了空状态、错误处理、加载状态等用户体验
### 下一步重点
1. 完善搜索功能(搜索历史、热门搜索)
2. 完善作品功能(发布、详情页面)
3. 前端架构优化MVVM、Repository模式
### 预计完成时间
- **搜索功能完善**: 2-3天
- **作品功能完善**: 3-4天
- **前端架构优化**: 5-7天
- **总计**: 约10-14个工作日2-3周
---
**汇报人**: [您的姓名]
**日期**: 2024年
**版本**: v1.0

View File

@ -0,0 +1,17 @@
package com.example.livestreaming.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class CreateRoomRequest {
@NotBlank(message = "标题不能为空")
private String title;
@NotBlank(message = "主播名称不能为空")
private String streamerName;
@NotBlank(message = "直播类型不能为空")
private String type;
}

View File

@ -0,0 +1,49 @@
package com.example.livestreaming.dto;
import com.example.livestreaming.entity.Room;
import lombok.Data;
@Data
public class RoomResponse {
private String id;
private String title;
private String streamerName;
private String type;
private String streamKey;
private boolean isLive;
private int viewerCount;
private String createdAt;
private String startedAt;
private StreamUrls streamUrls;
@Data
public static class StreamUrls {
private String rtmp;
private String flv;
private String hls;
}
public static RoomResponse fromEntity(Room room, String rtmpHost, int rtmpPort, String httpHost, int httpPort) {
RoomResponse response = new RoomResponse();
response.setId(room.getId());
response.setTitle(room.getTitle());
response.setStreamerName(room.getStreamerName());
response.setType(room.getType());
response.setStreamKey(room.getStreamKey());
response.setLive(room.isLive());
response.setViewerCount(room.getViewerCount());
response.setCreatedAt(room.getCreatedAt() != null ? room.getCreatedAt().toString() : null);
response.setStartedAt(room.getStartedAt() != null ? room.getStartedAt().toString() : null);
// 构建流地址
StreamUrls urls = new StreamUrls();
String streamKey = room.getStreamKey();
urls.setRtmp(String.format("rtmp://%s:%d/live/%s", rtmpHost, rtmpPort, streamKey));
urls.setFlv(String.format("http://%s:%d/live/%s.flv", httpHost, httpPort, streamKey));
urls.setHls(String.format("http://%s:%d/live/%s/index.m3u8", httpHost, httpPort, streamKey));
response.setStreamUrls(urls);
return response;
}
}