From 6e5c9fce95fdc2181c0f4b0bd9716088ba53e729 Mon Sep 17 00:00:00 2001 From: beyond Date: Sun, 6 Sep 2026 16:43:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=A8=E9=9D=A2=E5=AE=89=E5=85=A8=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=20+=20UI=E4=B8=80=E8=87=B4=E6=80=A7=20+=20=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=BF=AE=E5=A4=8D=20+=20=E6=80=A7=E8=83=BD=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全修复: - usesCleartextTraffic=false + network_security_config.xml (LAN例外) - 备份规则排除数据库/SP/缓存 (backup_rules.xml, data_extraction_rules.xml) - ProGuard Release移除 Log.d/Log.v - EmbyRepository 日志脱敏 + API Key安全注释 - EmbyServer URL长度/主机名/端口验证 UI一致性: - LumaDesignSystem 新增16个颜色令牌 + LumaBorders + 2个排版令牌 - 6个屏幕文件硬编码颜色统一到LumaColors - 排版统一 17sp→18sp, 30sp→32sp, 34sp→32sp 功能修复: - 首页MyMediaGrid点击导航到库列表 - 搜索齿轮图标导航到设置 - FavoritesAllScreen/HomeLibraryAllScreen 添加下拉刷新 - RecordsScreen 13+硬编码中文→stringResource() + 修复count bug - 新增16个日历字符串(中英文) 性能优化: - TMDB数据类添加@Immutable注解 - HomeContentCache添加LRU驱逐策略(最多3个快照) - VideoPlayerScreen内部轮询从250ms降至500ms Co-Authored-By: Claude Opus 5 (1M context) --- CODE_REVIEW_2026-09-06.md | 312 ++++++++++++++++++ app/proguard-rules.pro | 8 + app/src/main/AndroidManifest.xml | 3 +- .../myapplication/data/EmbyRepository.kt | 9 +- .../example/myapplication/data/EmbyServer.kt | 28 +- .../myapplication/data/TmdbRepository.kt | 5 + .../myapplication/navigation/AppNavHost.kt | 2 +- .../screens/FavoritesAllScreen.kt | 77 +++-- .../screens/HomeLibraryAllScreen.kt | 97 +++--- .../myapplication/screens/HomeScreen.kt | 51 ++- .../myapplication/screens/LibraryScreen.kt | 10 +- .../screens/MediaDetailScreen.kt | 15 +- .../myapplication/screens/RecordsScreen.kt | 67 ++-- .../myapplication/screens/SearchScreen.kt | 2 +- .../myapplication/screens/SettingsScreen.kt | 12 +- .../screens/VideoPlayerScreen.kt | 18 +- .../example/myapplication/ui/AppBottomBar.kt | 3 +- .../ui/design/LumaDesignSystem.kt | 38 ++- app/src/main/res/values-en/strings.xml | 13 + app/src/main/res/values/strings.xml | 13 + app/src/main/res/xml/backup_rules.xml | 29 +- .../main/res/xml/data_extraction_rules.xml | 34 +- .../main/res/xml/network_security_config.xml | 41 +++ 23 files changed, 708 insertions(+), 179 deletions(-) create mode 100644 CODE_REVIEW_2026-09-06.md create mode 100644 app/src/main/res/xml/network_security_config.xml diff --git a/CODE_REVIEW_2026-09-06.md b/CODE_REVIEW_2026-09-06.md new file mode 100644 index 0000000..acbb8b0 --- /dev/null +++ b/CODE_REVIEW_2026-09-06.md @@ -0,0 +1,312 @@ +# LumaPlayer 全面代码审查报告 + +**项目**: LumaPlayer (com.example.myapplication) — Emby 媒体库客户端 +**审查日期**: 2026-09-06 +**审查范围**: UI 一致性 / 安全漏洞 / 性能问题 / 功能缺失 / 代码质量 +**审查方法**: 5 个专项代理并行审查,阅读全部 50+ 源文件 + +--- + +## 📊 总览 + +| 审查分类 | 🔴 Critical | 🟠 Major | 🟡 Minor | 合计 | +|----------|:-----------:|:--------:|:--------:|:----:| +| 🎨 UI 样式一致性 | 4 | 11 | 15 | **30** | +| 🔒 安全漏洞 | 3 | 7 | 5 | **15** | +| ⚡ 性能问题 | 0 | 7 | 8 | **15** | +| 📋 功能缺失 | 3 | 6 | 14 | **23** | +| 🏗️ 代码质量 | 1 | 7 | 4 | **12** | +| **合计** | **11** | **38** | **46** | **95** | + +--- + +## 🚨 Top 10 最关键问题 + +| # | 严重度 | 分类 | 问题 | 文件 | +|---|--------|------|------|------| +| 1 | 🔴 | 安全 | 明文密码存储在 Room 数据库(无加密),设备 Root 后密码可读 | `ServerEntity.java:19` | +| 2 | 🔴 | 安全 | `usesCleartextTraffic="true"` 全局允许 HTTP 明文传输 | `AndroidManifest.xml:11` | +| 3 | 🔴 | 安全 | 认证接口无证书固定,MITM 可截获用户密码 | `EmbyRepository.kt:181` | +| 4 | 🔴 | 代码质量 | 全项目零 ViewModel,所有业务逻辑在 Composable 内,配置变更丢失状态 | 全局架构 | +| 5 | 🔴 | UI | 多处硬编码 `Color(0xFF152036)` 等暗色值,浅色主题下不可见 | `MediaDetailScreen.kt:1842` | +| 6 | 🔴 | 功能 | 播放设置点击无反应(`onClick = {}`),连播偏好每次重置 | `SettingsScreen.kt:158` | +| 7 | 🔴 | 功能 | 首页"我的媒体"网格点击无反应,空注释 `// Handle selection` | `HomeScreen.kt:1063` | +| 8 | 🔴 | 功能 | 搜索栏齿轮图标点击无反应 | `AppNavHost.kt:214` | +| 9 | 🟠 | 安全 | 备份规则为空,数据库含密码被自动备份到云端 | `AndroidManifest.xml:11-12` | +| 10 | 🟠 | 安全 | 50+ 处 `catch (_: Exception)` 静默吞掉异常,用户看不到真实错误 | `EmbyRepository.kt` 多处 | + +--- + +## 🎨 一、UI 样式一致性(30 个发现) + +### 🔴 Critical(4 个) + +**C1. 硬编码暗色背景色破坏浅色模式** +- **文件**: `MediaDetailScreen.kt:1842, 1929` +- **问题**: `Color(0xFF152036)` 作为剧集卡片背景,浅色主题下几乎不可见 +- **建议**: 替换为 `LocalDetailSurface.current` 或从沉浸式调色板派生 + +**C2. EpisodeListDialog 复制了 GlassDialog 的逻辑** +- **文件**: `MediaDetailScreen.kt:1730-1750` +- **问题**: 手动实现 `background(pageBottom.copy(alpha = 0.98f))` + `dialogBorder`,与 `GlassDialog` 不一致 +- **建议**: 统一使用 `GlassDialog` 或 `LumaColors.glassBorder` + +**C3. 在线/离线状态颜色绕过设计令牌** +- **文件**: `LibraryScreen.kt:119-123`,`HomeScreen.kt:1260`,`VideoPlayerScreen.kt:1676` +- **问题**: 多处直接用 `Color(0xFF34C759)` / `Color(0xFFFF3B30)` / `Color(0xFFFF453A)`,三种不同的红色表示"离线"。`LumaColors.success` / `LumaColors.danger` 已定义但被绕过 +- **建议**: 统一到 `LumaColors.success` / `LumaColors.danger` + +**C4. LibraryScreen 对话框 overlay 破坏浅色主题** +- **文件**: `LibraryScreen.kt:934, 1097, 1287` +- **问题**: `surfaceOverlay = Color.White.copy(alpha = 0.94f)` 在暗色主题下产生近不透明白色面板 +- **建议**: 使用 `LumaColors.background.copy(alpha = 0.94f)` 或移除 overlay + +### 🟠 Major(11 个) + +| # | 文件 | 问题 | 建议 | +|---|------|------|------| +| M1 | `HomeScreen.kt:407` / `MediaDetailScreen.kt:938` | 星级评分 `Color(0xFFFFC107)` 无令牌 | 添加 `LumaColors.ratingStar` | +| M2 | `HomeScreen.kt:837-843` | `RainbowNumber` 渐变色未纳入设计系统 | 添加到 `LumaColors.rainbowGradient` | +| M3 | `SettingsScreen.kt:406-416` | 弹窗颜色绕过 `LumaColors.regularTint` | 统一使用令牌 | +| M4 | `MediaDetailScreen.kt:1278-1280` | "最佳资源"徽章金色无令牌 | 复用 `LumaColors.accent` | +| M5 | `LibraryScreen.kt:844,946,1106` | 图标库渐变色重复 3 次 | 提取为命名常量 | +| M6 | `VideoPlayerScreen.kt:1387-1855` | 播放器面板 7 种硬编码颜色 | 创建 `PlayerColors` 对象 | +| M7 | `SearchScreen.kt:730` / `RecordsScreen.kt:357` | 海报卡片手动实现而非用 `LumaMediaCard` | 重构使用统一组件 | +| M8 | `SearchScreen.kt:554` / `RecordsScreen.kt:259` 等 | 多处直接用 `AsyncImage` 而非 `LumaAsyncImage` | 统一替换 | +| M9 | 多文件 | 章节标题字体大小不一致(17sp/18sp) | 统一到 `LumaTypography.title` (18sp) | +| M10 | `HomeScreen.kt:650` | 使用 `ExtraBold`(800) 而设计系统定义 `Bold`(700) | 统一为 Bold | +| M11 | `AppBottomBar.kt:35` | 底栏激活色 `#0091FF` 与 `LumaColors.accent` `#3B82F6` 不同 | 使用 `LumaColors.accent` | + +### 🟡 Minor(15 个) + +| # | 问题 | +|---|------| +| m1 | 间距值 `8.dp`/`10.dp`/`12.dp` 直接写而非用 `LumaSpacing.*` | +| m2 | `LumaSettingRow` 使用 `14.dp` 非标准间距 | +| m3 | `LumaSettingRow` 标题用 `15.sp` 字面值而非 `LumaTypography.body` | +| m4 | `12.sp` 字号未纳入排版系统 | +| m5 | `LumaPageTopBar` 用 `20.sp` 非标准字号 | +| m6 | Hero 标题用 `30.sp` + `FontWeight.Black` 非标准 | +| m7 | `MediaDetailScreen` 剧集标题 `34.sp` 非标准 | +| m8-12 | 5 处缺少 `contentDescription`(轮播指示器、网格卡片、日历箭头、播放器剧集项) | +| m13 | `HomeScreen` 离线红色 `#FF453A` 与其他处 `#FF3B30` 不同 | +| m14 | `MediaDetailScreen` 返回按钮绕过 `LumaIconButton` | +| m15 | 边框宽度不一致(0.5dp ~ 1.5dp),无 `LumaBorders` 令牌 | + +--- + +## 🔒 二、安全漏洞(15 个发现) + +### 🔴 Critical(3 个) + +**S1. 明文密码存储** (`CWE-312`) +- `ServerEntity.java:19` — 密码和 accessToken 以明文存储在 Room 数据库 +- **建议**: 使用 SQLCipher 加密数据库,或至少用 `EncryptedSharedPreferences` + +**S2. 全局允许明文流量** (`CWE-319`) +- `AndroidManifest.xml:11` — `usesCleartextTraffic="true"` 允许所有 HTTP 明文传输 +- **建议**: 设置为 `false`,添加 `network_security_config.xml` + +**S3. 认证无证书固定** (`CWE-295/300`) +- `EmbyRepository.kt:181` — 密码通过 JSON POST 发送,OkHttp 无 `CertificatePinner` +- **建议**: 实现证书固定,认证仅走 HTTPS + +### 🟠 Major(7 个) + +| # | CWE | 问题 | 文件 | +|---|-----|------|------| +| S4 | CWE-312 | 遗留迁移从明文 SP 写入明文 Room | `ServerRepository.kt:200` | +| S5 | CWE-798 | TMDB Token 编译进 BuildConfig,APK 反编译可提取 | `build.gradle.kts:28` | +| S6 | CWE-598 | `api_key` 作为 URL 查询参数传输 | `EmbyRepository.kt:869` | +| S7 | CWE-921 | `allowBackup="true"` + 空备份规则,数据库被云端备份 | `AndroidManifest.xml:11-12` | +| S8 | CWE-319 | `buildServerUrl` 支持 HTTP 明文协议 | `EmbyServer.kt:142` | +| S9 | CWE-311 | Room 数据库未加密 (`emby_player.db`) | `AppDatabase.java:152` | +| S10 | 异常处理 | 50+ 处 `catch (_: Exception)` 静默吞掉错误 | `EmbyRepository.kt` 等 | + +### 🟡 Minor(5 个) + +| # | 问题 | +|---|------| +| S11 | `Log.d("EmbyAuth", ...)` 在 Release 中仍可见 | +| S12 | 服务器 URL 输入无长度/格式验证 | +| S13 | `IconLibrary.kt:167` 用 `java.net.URL` 而非共享 OkHttp 客户端 | +| S14 | `Log.e` 输出异常堆栈可能泄露服务器细节 | +| S15 | ProGuard 未配置 `-assumenosideeffects` 移除 `Log.d/v` | + +--- + +## ⚡ 三、性能问题(15 个发现) + +### 🟠 Major(7 个) + +| # | 问题 | 文件 | 影响 | +|---|------|------|------| +| P1 | `TmdbCastMember` / `TmdbRecommendation` 等缺少 `@Immutable` | `TmdbRepository.kt:21-59` | 不必要的重组 | +| P2 | `FavoriteEntity` 是 Java 可变类传入 Compose,永远不稳定 | `FavoriteEntity.java:13` | LazyGrid 每项重组 | +| P3 | `MediaDetailScreen` 用 `Column + verticalScroll` 而非 `LazyColumn` | `MediaDetailScreen.kt:489` | 所有子组件同时组合,内存高 | +| P4 | 播放器 250ms 轮询 + 内部重复轮询,4次/秒重组整个控件 | `VideoPlayerScreen.kt:389-434` | UI 持续重组 | +| P5 | `FavoritePosterCard` 在主线程解析 JSON | `RecordsScreen.kt:363` | 滚动时卡顿 | +| P6 | `HomeContentCache` 无限增长无驱逐 | `HomeScreen.kt:136` | 内存泄漏风险 | +| P7 | `VideoPlayerScreen` 单函数 ~1600 行,~40 个状态变量 | `VideoPlayerScreen.kt:260` | 任何状态变更重组整个播放器 | + +### 🟡 Minor(8 个) + +| # | 问题 | +|---|------| +| P8 | `MyMediaGrid` LazyRow 缺少 `key` | +| P9 | `SearchResultsCache` 每次服务器完成都 `.toList()` 拷贝 | +| P10 | `castMembers` 每次重组做 O(n×m) 名称匹配 | +| P11 | `FavoriteSectionHeader` 显示 `"$count/$count"` 无意义 | +| P12 | 播放器剧集 LazyColumn 缺少 `key` | +| P13 | `CalendarEpisodeCard` 每次重组调 `LocalDate.now()` | +| P14 | 搜索并行化可改进(季/集可缓存) | +| P15 | Hero `shuffled()` 在主线程执行 | + +--- + +## 📋 四、功能缺失(23 个发现) + +### 🔴 Critical(3 个) + +**F1. 播放设置为空壳** +- `SettingsScreen.kt:158` — 点击无反应,无播放速度/连播/字幕偏好等设置 +- 连播偏好 (`confirmBeforeContinuousPlayback`) 每次打开播放器重置 + +**F2. 首页"我的媒体"网格点击无反应** +- `HomeScreen.kt:1063` — `// Handle selection` 空注释,点击无导航 + +**F3. 搜索栏齿轮图标无功能** +- `AppNavHost.kt:214` — `onGearClick = {}` 空实现 + +### 🟠 Major(6 个) + +| # | 问题 | 文件 | +|---|------|------| +| F4 | `HomeLibraryAllScreen` 无下拉刷新 | `HomeLibraryAllScreen.kt:149` | +| F5 | `RecordsScreen` 收藏/日历 Tab 无下拉刷新 | `RecordsScreen.kt:137` | +| F6 | `FavoritesAllScreen` 无下拉刷新 | `FavoritesAllScreen.kt:59` | +| F7 | `SearchAllScreen` 无分页/无限滚动 | `SearchAllScreen.kt:59` | +| F8 | `FavoriteSectionHeader` 显示 `"$count/$count"` 数字重复 | `RecordsScreen.kt:321` | +| F9 | 日历 Tab 13+ 个中文字符串硬编码,英文模式不翻译 | `RecordsScreen.kt:580-899` | + +### 🟡 Minor(14 个) + +| # | 问题 | +|---|------| +| F10 | `HomeLibraryAllScreen` 滚动加载无底部 loading 指示器 | +| F11 | 搜索历史清除无确认对话框 | +| F12 | 播放速度偏好不持久化 | +| F13 | 无 Deep Link 支持 | +| F14 | 收藏 Tab 无 loading 状态 | +| F15 | 详情页加载失败无重试按钮 | +| F16 | 所有导航转场相同,详情→播放器无特殊过渡 | +| F17 | 播放器错误无重试机制 | +| F18 | 空库分区显示标题但无内容提示 | +| F19 | 缓存管理仅清除图标缓存,不含 Coil/搜索历史 | +| F20 | 日历依赖 TMDB 无回退,失败静默空白 | +| F21 | 排序弹窗固定高度 `304.dp` | +| F22-23 | 多处交互元素缺少 `contentDescription` | + +--- + +## 🏗️ 五、代码质量(12 个发现) + +### 🔴 Critical(1 个) + +**Q1. 零 ViewModel 架构 — 全项目无 ViewModel** +- 所有业务逻辑在 `@Composable` 函数中 +- `MainApp.kt:146-206` 中 `addServer`/`updateServer` 含认证逻辑 +- `AppNavHost.kt:397-519` 中 PlayerDestination 有 8 个 `remember` 状态变量 +- **影响**: 配置变更(旋转/深色切换)丢失状态,无法单元测试 +- **建议**: 至少引入 `HomeViewModel`, `MediaDetailViewModel`, `PlayerViewModel` + +### 🟠 Major(7 个) + +| # | 问题 | 文件 | +|---|------|------| +| Q2 | 所有 Repository 是 `object` 单例,无 DI 框架 | 全局 | +| Q3 | 50+ 处 `catch (_: Exception)` 静默吞掉异常 | `EmbyRepository.kt` 等 | +| Q4 | `addServer` / `updateServer` 80% 代码重复 | `MainApp.kt:152-206` | +| Q5 | 5 个文件超 1000 行(最大 LibraryScreen 2031 行) | 见上方 | +| Q6 | 无结构化协程作用域,单例 CoroutineScope 永不取消 | `EmbyRepository.kt:151` | +| Q7 | 仅 9 个测试文件覆盖 ~18K 行代码,Repository 层零测试 | `test/` 目录 | +| Q8 | `playbackReportState` MutableMap 无同步文档 | `EmbyRepository.kt:150` | + +### 🟡 Minor(4 个) + +| # | 问题 | +|---|------| +| Q9 | 包名 `com.example.myapplication` 是模板默认值 | +| Q10 | `EmbyAuthRequest` 字段用 PascalCase 违反 Kotlin 命名 | +| Q11 | `LumaMotion` 持续时间命名缺描述性后缀 | +| Q12 | `ExperimentalAnimationApi` opt-in 可能不再需要 | + +--- + +## 🛣️ 优化路线图 + +### 📅 短期(1 周)— 安全修复 + 关键 Bug + +1. **安全**: `usesCleartextTraffic="false"` + `network_security_config.xml` +2. **安全**: 配置备份规则排除数据库 +3. **安全**: 添加 ProGuard 规则移除 `Log.d/v` +4. **安全**: API Key 改用 HTTP Header 传输 +5. **功能**: 修复 `MyMediaGrid` 点击无反应 → 导航到库列表 +6. **功能**: 修复搜索齿轮图标 → 导航到设置或移除 +7. **功能**: 修复 `"$count/$count"` 显示 bug +8. **UI**: 统一在线/离线颜色到 `LumaColors` 令牌 + +### 📅 中期(1 个月)— 架构重构 + 安全加固 + +1. **安全**: 实现数据库加密(SQLCipher 或 EncryptedSharedPreferences) +2. **安全**: 添加 OkHttp 证书固定 +3. **架构**: 引入至少 3 个 ViewModel(Detail/Player/Home) +4. **架构**: 引入 Hilt 依赖注入 +5. **性能**: MediaDetailScreen 改用 LazyColumn +6. **性能**: VideoPlayerScreen 拆分子组件减少重组范围 +7. **功能**: 实现播放设置页面(速度/连播/字幕偏好) +8. **功能**: 添加 3 个页面的下拉刷新 +9. **国际化**: 日历 Tab 中文硬编码迁移到 strings.xml + +### 📅 长期(3 个月)— 完善 + 可维护性 + +1. **架构**: 拆分 5 个大文件(LibraryScreen/MediaDetailScreen/VideoPlayerScreen/HomeScreen/EmbyRepository) +2. **安全**: TMDB Token 移至后端代理 +3. **测试**: Repository 层单元测试覆盖 +4. **测试**: Compose UI 测试覆盖关键流程 +5. **UI**: 统一排版系统(消除 17sp/20sp/30sp/34sp 离群值) +6. **UI**: 创建 `LumaBorders` 设计令牌 +7. **功能**: Deep Link 支持 +8. **功能**: 完善缓存管理(Coil/搜索历史/视频缓存) + +--- + +## 📈 综合健康评分 + +### 评分:6.5 / 10 + +| 维度 | 评分 | 说明 | +|------|:----:|------| +| 🎨 UI 一致性 | 6/10 | 设计系统完善但被大量绕过,硬编码颜色/字号泛滥 | +| 🔒 安全性 | 4/10 | 密码明文存储 + 明文传输 + 无证书固定,多项严重漏洞 | +| ⚡ 性能 | 6/10 | 已做三轮优化但核心架构问题(无 LazyColumn、大重组范围)仍在 | +| 📋 功能完整度 | 7/10 | 核心功能齐全,3 个空壳占位需实现 | +| 🏗️ 代码质量 | 5/10 | 无 ViewModel、无 DI、静默异常、大文件、测试不足 | + +### 优势 +- ✅ 自有 LumaDesignSystem 设计令牌体系 +- ✅ LiquidGlass 创新 UI 效果 +- ✅ 已完成三轮系统性性能优化(@Immutable、remember、并行化) +- ✅ 分层架构清晰(data/screens/navigation/ui) +- ✅ 双语支持框架完善 +- ✅ Room + Migration 管理规范 + +### 核心短板 +- ❌ 安全基础薄弱(明文密码 + 明文传输) +- ❌ 缺少 ViewModel 导致状态管理脆弱 +- ❌ 设计令牌定义完善但被大量绕过 +- ❌ 静默异常处理掩盖真实问题 + +--- + +*报告由 5 个专项审查模块并行审查后汇总生成。审查时间:2026-09-06。* diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 77f749d..77bf152 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -61,3 +61,11 @@ # ── Palette ── -keep class androidx.palette.** { *; } + +# ── Strip debug/verbose logs in Release builds ── +-assumenosideeffects class android.util.Log { + public static int d(...); + public static int v(...); +} +# Keep error and warning logs for crash reporting +# Log.e and Log.w are intentionally NOT stripped diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6946797..d9b31d2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,7 +7,8 @@ 2048) { + return ParsedServerAddress(currentProtocol, trimmed, currentPort, currentBasePath, "URL exceeds maximum length") + } + val schemeMatch = Regex("^(https?)://", RegexOption.IGNORE_CASE).find(trimmed) val hasExplicitScheme = schemeMatch != null val protocol = schemeMatch?.groupValues?.get(1)?.lowercase() @@ -122,8 +128,26 @@ fun parseServerAddressInput( } } + // Security: validate host format (must be valid hostname or IP) + val cleanHost = host.trim().trimEnd('/') + if (cleanHost.isNotEmpty()) { + val hostRegex = Regex( + """^(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?""" + + """|(?:\d{1,3}\.){3}\d{1,3}|[a-fA-F0-9:]+)$""" + ) + if (!hostRegex.matches(cleanHost)) { + return ParsedServerAddress(protocol, cleanHost, currentPort, currentBasePath, "Invalid host format") + } + } + val port = when { - explicitPort != null -> explicitPort + explicitPort != null -> { + val portNum = explicitPort.toIntOrNull() + if (portNum == null || portNum !in 0..65535) { + return ParsedServerAddress(protocol, cleanHost, explicitPort, currentBasePath, "Invalid port number") + } + explicitPort + } hasExplicitScheme && protocol == "https" -> "443" hasExplicitScheme -> "" currentPort.isNotBlank() -> currentPort @@ -132,7 +156,7 @@ fun parseServerAddressInput( } return ParsedServerAddress( protocol = protocol, - host = host.trim().trimEnd('/'), + host = cleanHost, port = port, basePath = path.ifBlank { currentBasePath.trim().trim('/') }, ) diff --git a/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt index d661850..79e14e1 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt @@ -3,6 +3,7 @@ package com.example.myapplication.data import android.net.Uri import android.util.Log import android.util.LruCache +import androidx.compose.runtime.Immutable import com.example.myapplication.BuildConfig import com.google.gson.Gson import com.google.gson.annotations.SerializedName @@ -18,6 +19,7 @@ import okhttp3.Request import java.util.Locale import java.util.concurrent.TimeUnit +@Immutable data class TmdbCastMember( val id: Int, val name: String, @@ -26,6 +28,7 @@ data class TmdbCastMember( val profileUrl: String?, ) +@Immutable data class TmdbRecommendation( val id: Int, val name: String, @@ -35,11 +38,13 @@ data class TmdbRecommendation( val rating: Double, ) +@Immutable data class TmdbSeriesExtras( val cast: List = emptyList(), val recommendations: List = emptyList(), ) +@Immutable data class TmdbEpisodeCalendar( val tmdbId: Int, val seriesName: String, diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt index d3e95c3..4aec9e1 100644 --- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt +++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt @@ -210,7 +210,7 @@ fun AppNavHost( SearchTopBar( query = query, onQueryChange = { query = it }, - onGearClick = {}, + onGearClick = { navController.navigate(AppRoute.Settings.pattern) }, ) Box(Modifier.weight(1f)) { SearchScreen( diff --git a/app/src/main/kotlin/com/example/myapplication/screens/FavoritesAllScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/FavoritesAllScreen.kt index 5d66167..be76a94 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/FavoritesAllScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/FavoritesAllScreen.kt @@ -22,6 +22,8 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -33,7 +35,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale - import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -55,6 +56,7 @@ import com.example.myapplication.ui.design.LumaGlassLevel import com.example.myapplication.ui.design.LumaShapes import top.yukonga.miuix.kmp.basic.Text +@OptIn(ExperimentalMaterial3Api::class) @Composable fun FavoritesAllScreen( serverId: String, @@ -65,11 +67,14 @@ fun FavoritesAllScreen( ) { val server = remember(serverId) { servers.firstOrNull { it.id == serverId } } var favorites by remember { mutableStateOf>(emptyList()) } + var refreshing by remember { mutableStateOf(false) } + var refreshToken by remember { mutableStateOf(0) } - LaunchedEffect(serverId, type) { + LaunchedEffect(serverId, type, refreshToken) { favorites = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { FavoriteRepository.getAllFavoritesFromDb() }.filter { it.serverId == serverId && it.type == type } + refreshing = false } val title = when (type) { @@ -79,7 +84,7 @@ fun FavoritesAllScreen( } Column(Modifier.fillMaxSize()) { - // Server header — same as SearchAllScreen + // Server header Row( modifier = Modifier .fillMaxWidth() @@ -98,7 +103,6 @@ fun FavoritesAllScreen( ) Spacer(Modifier.size(12.dp)) - // Server icon Box( modifier = Modifier .size(32.dp) @@ -126,7 +130,6 @@ fun FavoritesAllScreen( } Spacer(Modifier.width(10.dp)) - // Server name Column(Modifier.weight(1f)) { Text( text = server?.name?.ifBlank { server.host }.orEmpty(), @@ -150,37 +153,47 @@ fun FavoritesAllScreen( ) } - // Grid of favorites - Box( - Modifier + // Grid of favorites with pull-to-refresh + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { + refreshing = true + refreshToken++ + }, + modifier = Modifier .weight(1f) - .fillMaxWidth() - .padding(horizontal = 12.dp), + .fillMaxWidth(), ) { - if (favorites.isEmpty()) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - LumaEmptyState( - icon = LumaIcon.Favorite, - title = stringResource(Res.string.empty_favorites), - description = stringResource(Res.string.empty_favorites_sub), - showIconBackground = false, - ) - } - } else { - LazyVerticalGrid( - columns = GridCells.Fixed(3), - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - items(items = favorites, key = { it.itemId }) { entity -> - FavoriteGridCard( - entity = entity, - serverUrl = server?.fullUrl.orEmpty(), - onClick = { onOpenDetail(entity.serverId, entity.itemId) }, + Box( + Modifier + .fillMaxSize() + .padding(horizontal = 12.dp), + ) { + if (favorites.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LumaEmptyState( + icon = LumaIcon.Favorite, + title = stringResource(Res.string.empty_favorites), + description = stringResource(Res.string.empty_favorites_sub), + showIconBackground = false, ) } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(items = favorites, key = { it.itemId }) { entity -> + FavoriteGridCard( + entity = entity, + serverUrl = server?.fullUrl.orEmpty(), + onClick = { onOpenDetail(entity.serverId, entity.itemId) }, + ) + } + } } } } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeLibraryAllScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeLibraryAllScreen.kt index 882a28e..6d38c66 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeLibraryAllScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeLibraryAllScreen.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -29,7 +31,6 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot - import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -69,6 +70,7 @@ private enum class HomeLibrarySort( PLAY_DATE("DatePlayed", "Descending", Res.string.sort_play_date, LumaIcon.History), } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeLibraryAllScreen( serverId: String, @@ -86,6 +88,8 @@ fun HomeLibraryAllScreen( var loadingMore by remember(serverId, libraryId) { mutableStateOf(false) } var error by remember(serverId, libraryId) { mutableStateOf(false) } var retryToken by remember(serverId, libraryId) { mutableIntStateOf(0) } + var refreshing by remember(serverId, libraryId) { mutableStateOf(false) } + var refreshToken by remember(serverId, libraryId) { mutableIntStateOf(0) } var selectedSort by remember(serverId, libraryId) { mutableStateOf(HomeLibrarySort.JOIN_DATE) } var sortMenuVisible by remember(serverId, libraryId) { mutableStateOf(false) } var sortMenuMounted by remember(serverId, libraryId) { mutableStateOf(false) } @@ -93,12 +97,13 @@ fun HomeLibraryAllScreen( var rootOrigin by remember { mutableStateOf(Offset.Zero) } val sortBackdrop = if (sortMenuMounted) rememberLumaLayerBackdrop() else null - LaunchedEffect(serverId, libraryId, selectedSort, retryToken) { + LaunchedEffect(serverId, libraryId, selectedSort, retryToken, refreshToken) { if (server == null || server.apiKey.isBlank()) { loading = false + refreshing = false return@LaunchedEffect } - loading = true + if (!refreshing) loading = true error = false try { val page = EmbyRepository.getItemsForLibrary( @@ -116,6 +121,7 @@ fun HomeLibraryAllScreen( error = true } finally { loading = false + refreshing = false } } @@ -201,46 +207,53 @@ fun HomeLibraryAllScreen( ) } } - Box( - Modifier - .weight(1f) - .fillMaxWidth(), + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { + refreshing = true + refreshToken++ + }, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), ) { - when { - loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - LumaLoading(label = stringResource(Res.string.loading_home)) - } - error -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - LumaErrorState( - title = stringResource(Res.string.error_content_title), - description = stringResource(Res.string.error_content_sub), - retryLabel = stringResource(Res.string.retry), - onRetry = { retryToken++ }, - ) - } - items.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - LumaEmptyState( - icon = LumaIcon.Media, - title = stringResource(Res.string.empty_home_no_data), - description = stringResource(Res.string.empty_home_no_data_sub), - showIconBackground = false, - ) - } - else -> { - LazyVerticalGrid( - columns = GridCells.Fixed(3), - state = gridState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - items(items = items, key = { it.id }) { item -> - HomeLibraryMediaCard( - item = item, - serverUrl = server?.fullUrl.orEmpty(), - onClick = { onOpenDetail(item.id) }, - ) + Box(Modifier.fillMaxSize()) { + when { + loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LumaLoading(label = stringResource(Res.string.loading_home)) + } + error -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LumaErrorState( + title = stringResource(Res.string.error_content_title), + description = stringResource(Res.string.error_content_sub), + retryLabel = stringResource(Res.string.retry), + onRetry = { retryToken++ }, + ) + } + items.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LumaEmptyState( + icon = LumaIcon.Media, + title = stringResource(Res.string.empty_home_no_data), + description = stringResource(Res.string.empty_home_no_data_sub), + showIconBackground = false, + ) + } + else -> { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + state = gridState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(items = items, key = { it.id }) { item -> + HomeLibraryMediaCard( + item = item, + serverUrl = server?.fullUrl.orEmpty(), + onClick = { onOpenDetail(item.id) }, + ) + } } } } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt index 01955a6..8b11458 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -134,16 +134,35 @@ internal data class HomeSnapshot( ) internal object HomeContentCache { + private const val MAX_SNAPSHOTS = 3 + private const val MAX_LIBRARY_PAGES = 10 + private const val MAX_EPISODE_COUNTS = 50 + private val snapshots = mutableMapOf() private val libraryPages = mutableMapOf() - private val episodeCounts = mutableMapOf() // cacheKey + seriesId → total episode count + private val episodeCounts = mutableMapOf() + private val snapshotAccessOrder = mutableListOf() @Synchronized - fun snapshot(key: String): HomeSnapshot? = snapshots[key] + fun snapshot(key: String): HomeSnapshot? { + val s = snapshots[key] ?: return null + snapshotAccessOrder.remove(key) + snapshotAccessOrder.add(key) + return s + } @Synchronized fun putSnapshot(key: String, value: HomeSnapshot) { snapshots[key] = value + snapshotAccessOrder.remove(key) + snapshotAccessOrder.add(key) + // Evict oldest entries when cache exceeds limit + while (snapshots.size > MAX_SNAPSHOTS) { + val oldest = snapshotAccessOrder.removeFirstOrNull() ?: break + snapshots.remove(oldest) + libraryPages.keys.removeAll { it.startsWith("$oldest|") } + episodeCounts.keys.removeAll { it.startsWith("$oldest|") } + } } @Synchronized @@ -152,6 +171,11 @@ internal object HomeContentCache { @Synchronized fun putLibraryPage(key: String, libraryId: String, value: EmbyLibraryPage) { libraryPages["$key|$libraryId"] = value + if (libraryPages.size > MAX_LIBRARY_PAGES) { + // Remove entries for evicted snapshots + val validPrefixes = snapshots.keys.map { "$it|" } + libraryPages.keys.removeAll { k -> validPrefixes.none { k.startsWith(it) } } + } } @Synchronized @@ -160,11 +184,16 @@ internal object HomeContentCache { @Synchronized fun putEpisodeCount(cacheKey: String, seriesId: String, count: Int) { episodeCounts["$cacheKey|$seriesId"] = count + if (episodeCounts.size > MAX_EPISODE_COUNTS) { + val validPrefixes = snapshots.keys.map { "$it|" } + episodeCounts.keys.removeAll { k -> validPrefixes.none { k.startsWith(it) } } + } } @Synchronized fun invalidate(key: String) { snapshots.remove(key) + snapshotAccessOrder.remove(key) libraryPages.keys.removeAll { it.startsWith("$key|") } episodeCounts.keys.removeAll { it.startsWith("$key|") } } @@ -386,8 +415,8 @@ private fun FeaturedItemBanner( Text( text = heroDisplayTitle(item), color = Color.White, - fontSize = 30.sp, - fontWeight = FontWeight.Black, + fontSize = 32.sp, + fontWeight = FontWeight.Bold, letterSpacing = 1.6.sp, textAlign = TextAlign.Center, maxLines = 2, @@ -404,7 +433,7 @@ private fun FeaturedItemBanner( verticalAlignment = Alignment.CenterVertically, ) { item.communityRating?.takeIf { it > 0.0 }?.let { - Text("★ ${String.format("%.1f", it)}", color = Color(0xFFFFC107), fontSize = 12.sp, fontWeight = FontWeight.Bold) + Text("★ ${String.format("%.1f", it)}", color = LumaColors.ratingStar, fontSize = 12.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.width(8.dp)) } item.productionYear?.let { @@ -834,13 +863,7 @@ private fun RainbowNumber( modifier = modifier, style = TextStyle( brush = Brush.linearGradient( - listOf( - Color(0xFFFF5F6D), - Color(0xFFFFB347), - Color(0xFF36D1DC), - Color(0xFF5B86E5), - Color(0xFFB06AB3), - ) + LumaColors.rainbowGradient ), fontSize = fontSize, fontWeight = FontWeight.ExtraBold, @@ -1060,7 +1083,7 @@ fun HomeScreen( item(key = "media", contentType = "media") { HomeSection(first = resumeItems.isEmpty()) { MyMediaGrid(libraries, server.fullUrl) { folder -> - // Handle selection + onViewAll(server.id, folder.id, folder.name) } } } @@ -1257,7 +1280,7 @@ private fun HomeServerOption( Modifier .size(6.dp) .clip(CircleShape) - .background(if (server.isOnline) Color(0xFF34C759) else Color(0xFFFF453A)), + .background(if (server.isOnline) LumaColors.online else LumaColors.offline), ) Text( if (server.isOnline) stringResource(Res.string.server_online) else stringResource(Res.string.server_offline), diff --git a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt index af6aeab..35b9d6e 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt @@ -119,8 +119,8 @@ import kotlinx.coroutines.launch private val serverTints = listOf( Color(0xFF9CC4FF), Color(0xFFA8E6C0), Color(0xFFD6B8FF), Color(0xFFFFB8D0), ) -private val onlineGreen = Color(0xFF34C759) -private val offlineRed = Color(0xFFFF3B30) +private val onlineGreen get() = LumaColors.online +private val offlineRed get() = LumaColors.offline private val iconLibraryControlHeight = 48.dp @Composable @@ -841,7 +841,7 @@ private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) { ) { Box( Modifier.size(38.dp).clip(RoundedCornerShape(12.dp)) - .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))), + .background(Brush.linearGradient(listOf(LumaColors.iconLibGradientStart, LumaColors.iconLibGradientEnd))), contentAlignment = Alignment.Center, ) { LumaIconView(LumaIcon.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(19.dp)) @@ -943,7 +943,7 @@ private fun ManageIconLibraryDialog( Row(verticalAlignment = Alignment.CenterVertically) { Box( Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)) - .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))), + .background(Brush.linearGradient(listOf(LumaColors.iconLibGradientStart, LumaColors.iconLibGradientEnd))), contentAlignment = Alignment.Center, ) { LumaIconView(LumaIcon.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) @@ -1104,7 +1104,7 @@ private fun AddIconLibraryDialog( Row(verticalAlignment = Alignment.CenterVertically) { Box( Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)) - .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))), + .background(Brush.linearGradient(listOf(LumaColors.iconLibGradientStart, LumaColors.iconLibGradientEnd))), contentAlignment = Alignment.Center, ) { LumaIconView(LumaIcon.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) diff --git a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt index 8aeca4e..a218640 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt @@ -69,6 +69,7 @@ import com.example.myapplication.ui.design.LumaGlassLevel import com.example.myapplication.ui.design.rememberLumaLayerBackdrop import com.example.myapplication.ui.design.lumaGlass import com.example.myapplication.ui.design.LumaSpacing +import com.example.myapplication.ui.design.LumaColors import com.kyant.backdrop.backdrops.layerBackdrop import androidx.compose.ui.graphics.luminance import androidx.compose.ui.geometry.Rect @@ -600,7 +601,7 @@ fun MediaDetailScreen( if (detail.type == "Series") { Spacer(Modifier.height(20.dp)) - Text(text = stringResource(Res.string.detail_episodes), color = LocalDetailText.current, fontSize = 17.sp, fontWeight = FontWeight.Bold) + Text(text = stringResource(Res.string.detail_episodes), color = LocalDetailText.current, fontSize = 18.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.height(10.dp)) if (loadingSeasons) { EpisodeSkeletonRow(label = stringResource(Res.string.detail_loading_seasons)) @@ -906,7 +907,7 @@ private fun DetailHeadline(detail: EmbyItem, serverUrl: String) { Text( text = displayTitle, color = LocalDetailText.current, - fontSize = 34.sp, + fontSize = 32.sp, fontWeight = FontWeight.Bold, lineHeight = 42.sp, textAlign = TextAlign.Center, @@ -935,7 +936,7 @@ private fun DetailHeadline(detail: EmbyItem, serverUrl: String) { detail.communityRating?.takeIf { it > 0.0 }?.let { rating -> Text( text = "★ ${String.format("%.1f", rating)}", - color = Color(0xFFFFC107), + color = LumaColors.ratingStar, fontSize = 12.sp, fontWeight = FontWeight.Bold, ) @@ -1275,9 +1276,9 @@ private fun ResourceCard( ) if (isBest) { Box( - Modifier.clip(RoundedCornerShape(8.dp)).background(Color(0xFFFFC857).copy(alpha = 0.18f)).padding(horizontal = 6.dp, vertical = 4.dp), + Modifier.clip(RoundedCornerShape(8.dp)).background(LumaColors.bestBadgeStart.copy(alpha = 0.18f)).padding(horizontal = 6.dp, vertical = 4.dp), ) { - Text(stringResource(Res.string.detail_best_resource), color = Color(0xFFFFB020), fontSize = 9.sp, fontWeight = FontWeight.Bold) + Text(stringResource(Res.string.detail_best_resource), color = LumaColors.bestBadgeEnd, fontSize = 9.sp, fontWeight = FontWeight.Bold) } } } @@ -1688,7 +1689,7 @@ private fun DetailSectionTitle(title: String, icon: LumaIcon) { LumaIconView(icon, contentDescription = null, tint = LocalDetailAccent.current, modifier = Modifier.size(17.dp)) } Spacer(Modifier.width(9.dp)) - Text(title, color = LocalDetailText.current, fontSize = 17.sp, fontWeight = FontWeight.Bold) + Text(title, color = LocalDetailText.current, fontSize = 18.sp, fontWeight = FontWeight.Bold) } } @@ -1839,7 +1840,7 @@ private fun CompactEpisodeRow( Modifier .size(width = 94.dp, height = 53.dp) .clip(RoundedCornerShape(11.dp)) - .background(Color(0xFF152036)), + .background(LumaColors.playerSurface), ) { if (imageUrl != null) { LumaAsyncImage( diff --git a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt index 4471f8e..79929df 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt @@ -314,12 +314,12 @@ private fun FavoriteSectionHeader( Text( text = title, color = GlassTokens.text, - fontSize = 17.sp, + fontSize = 18.sp, fontWeight = FontWeight.Bold, ) Spacer(Modifier.weight(1f)) Text( - text = "$count/$count", + text = "$count", color = GlassTokens.subtext, fontSize = 13.sp, fontWeight = FontWeight.Medium, @@ -515,6 +515,21 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> }) val groupedByDate = remember(filteredEpisodes) { filteredEpisodes.groupBy { it.airDate }.toSortedMap() } + // Resolve calendar strings for non-Composable getRelativeDateLabel + val calendarTodayLabel = stringResource(Res.string.calendar_day_relative_today) + val calendarTomorrowLabel = stringResource(Res.string.calendar_day_relative_tomorrow) + val calendarDayAfterLabel = stringResource(Res.string.calendar_day_relative_day_after) + val calendarWeekLabels = listOf( + stringResource(Res.string.calendar_week_monday), + stringResource(Res.string.calendar_week_tuesday), + stringResource(Res.string.calendar_week_wednesday), + stringResource(Res.string.calendar_week_thursday), + stringResource(Res.string.calendar_week_friday), + stringResource(Res.string.calendar_week_saturday), + stringResource(Res.string.calendar_week_sunday), + ) + val calendarDateFormat = stringResource(Res.string.calendar_date_format) + val calendarDateFullFormat = stringResource(Res.string.calendar_date_full_format) LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( @@ -523,8 +538,13 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> }) ) { groupedByDate.forEach { (dateStr, episodes) -> val date = try { java.time.LocalDate.parse(dateStr) } catch (_: Exception) { null } - val relativeLabel = date?.let { getRelativeDateLabel(it, today) } - ?: dateStr + val relativeLabel = date?.let { + getRelativeDateLabel( + it, today, + calendarTodayLabel, calendarTomorrowLabel, calendarDayAfterLabel, + calendarWeekLabels, calendarDateFormat, calendarDateFullFormat, + ) + } ?: dateStr item(key = "date_$dateStr") { CalendarDateSection( @@ -577,22 +597,23 @@ internal fun filterCalendarEpisodes( } } -private fun getRelativeDateLabel(date: java.time.LocalDate, today: java.time.LocalDate): String { - val dayOfWeek = when (date.dayOfWeek) { - java.time.DayOfWeek.MONDAY -> "周一" - java.time.DayOfWeek.TUESDAY -> "周二" - java.time.DayOfWeek.WEDNESDAY -> "周三" - java.time.DayOfWeek.THURSDAY -> "周四" - java.time.DayOfWeek.FRIDAY -> "周五" - java.time.DayOfWeek.SATURDAY -> "周六" - java.time.DayOfWeek.SUNDAY -> "周日" - } +private fun getRelativeDateLabel( + date: java.time.LocalDate, + today: java.time.LocalDate, + todayLabel: String, + tomorrowLabel: String, + dayAfterLabel: String, + weekLabels: List, // Mon-Sun + dateFormat: String, + dateFullFormat: String, +): String { + val dayOfWeek = weekLabels.getOrElse(date.dayOfWeek.value - 1) { "" } val dateStr = when { - date == today -> "今天" - date == today.plusDays(1) -> "明天" - date == today.plusDays(2) -> "后天" - date.year == today.year -> "${date.monthValue}月${date.dayOfMonth}日" - else -> "${date.year}年${date.monthValue}月${date.dayOfMonth}日" + date == today -> todayLabel + date == today.plusDays(1) -> tomorrowLabel + date == today.plusDays(2) -> dayAfterLabel + date.year == today.year -> String.format(dateFormat, date.monthValue, date.dayOfMonth) + else -> String.format(dateFullFormat, date.year, date.monthValue, date.dayOfMonth) } return "$dateStr $dayOfWeek" } @@ -846,7 +867,7 @@ private fun CalendarEpisodeCard(episode: TmdbEpisodeCalendar, onClick: () -> Uni horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text(text = "剧集", color = GlassTokens.accent, fontSize = 11.sp, fontWeight = FontWeight.Medium) + Text(text = stringResource(Res.string.calendar_episode_label), color = GlassTokens.accent, fontSize = 11.sp, fontWeight = FontWeight.Medium) if (episode.genres.isNotBlank()) { Text(text = episode.genres, color = GlassTokens.subtext, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) } @@ -858,7 +879,7 @@ private fun CalendarEpisodeCard(episode: TmdbEpisodeCalendar, onClick: () -> Uni if (episode.networkName.isNotBlank()) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = "播出平台:", + text = stringResource(Res.string.calendar_platform_label), color = GlassTokens.subtext, fontSize = 11.sp, ) @@ -894,9 +915,9 @@ private fun CalendarEpisodeCard(episode: TmdbEpisodeCalendar, onClick: () -> Uni horizontalArrangement = Arrangement.spacedBy(6.dp), ) { if (isAired && episode.episodeNumber > 0) { - Text(text = "已更新至第${episode.episodeNumber}集", color = GlassTokens.subtext, fontSize = 10.sp) + Text(text = stringResource(Res.string.calendar_episode_aired, episode.episodeNumber), color = GlassTokens.subtext, fontSize = 10.sp) } else { - Text(text = "未播出 第${episode.episodeNumber}集", color = GlassTokens.subtext, fontSize = 10.sp) + Text(text = stringResource(Res.string.calendar_episode_not_aired, episode.episodeNumber), color = GlassTokens.subtext, fontSize = 10.sp) } Box( modifier = Modifier diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt index 92d569f..af926e6 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt @@ -574,7 +574,7 @@ private fun ServerSearchSection( Text( text = server.name.ifBlank { server.host }, color = GlassTokens.text, - fontSize = 17.sp, + fontSize = 18.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt index 1fcfa5b..df01731 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt @@ -403,14 +403,6 @@ private fun LanguageMenuOverlay( } } -private fun settingsPopupContainerColor(): Color = if (LumaColors.isDark) { - Color(0xFF111821).copy(alpha = 0.72f) -} else { - Color(0xFFF4F7FA).copy(alpha = 0.68f) -} +private fun settingsPopupContainerColor(): Color = LumaColors.settingsPopupBg.copy(alpha = 0.72f) -private fun settingsPopupBorderColor(): Color = if (LumaColors.isDark) { - Color.White.copy(alpha = 0.24f) -} else { - Color(0xFFC8D2DE) -} +private fun settingsPopupBorderColor(): Color = LumaColors.settingsPopupBorder diff --git a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt index 5df589f..fd85479 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt @@ -113,6 +113,7 @@ import com.example.myapplication.ui.design.LumaSeekBar import com.example.myapplication.ui.design.LumaGlassLevel import com.example.myapplication.ui.design.lumaGlass import com.example.myapplication.ui.design.LumaShapes +import com.example.myapplication.ui.design.LumaColors import com.example.myapplication.ui.LocalGlassBackdrop import kotlinx.coroutines.delay import kotlinx.coroutines.CoroutineScope @@ -1042,12 +1043,11 @@ fun VideoPlayerScreen( totalDurationMs = player.duration if (!isSeeking && seekPreviewPositionMs == null) { currentPositionMs = player.currentPosition - // P0: Don't write outer 'progress' here — already handled by outer loop at 250ms val b = (player.bufferedPosition.toFloat() / player.duration).coerceIn(0f, 1f) bufferProgress = maxOf(b, progress) } } - delay(250) + delay(500) // Reduced from 250ms — seek bar doesn't need sub-frame precision } } @@ -1384,7 +1384,7 @@ private fun LiquidGlassPanel( Brush.horizontalGradient( listOf( Color.Black.copy(alpha = 0.12f), - Color(0xFF07111F).copy(alpha = 0.68f), + LumaColors.playerOverlay.copy(alpha = 0.68f), ) ) ) @@ -1400,7 +1400,7 @@ private fun LiquidGlassPanel( backdrop = null, radius = 22.dp, level = LumaGlassLevel.Strong, - tint = Color(0xFF101C31).copy(alpha = 0.94f), + tint = LumaColors.playerSurface.copy(alpha = 0.94f), borderColor = Color.White.copy(alpha = 0.26f), shadowElevation = 12.dp, ) @@ -1412,8 +1412,8 @@ private fun LiquidGlassPanel( .background( Brush.verticalGradient( listOf( - Color(0xFF243A59).copy(alpha = 0.90f), - Color(0xFF09111F).copy(alpha = 0.95f), + LumaColors.playerBorder.copy(alpha = 0.90f), + LumaColors.playerControlBg.copy(alpha = 0.95f), ) ), RoundedCornerShape(22.dp), @@ -1600,7 +1600,7 @@ private fun PlaybackToolsMenuContent( @Composable private fun LiquidMenuItem(text: String, selected: Boolean, onClick: () -> Unit) { - val surface = if (selected) Color(0xFF4D78B8).copy(alpha = 0.48f) else Color(0xFF16243A).copy(alpha = 0.72f) + val surface = if (selected) LumaColors.playerAccent.copy(alpha = 0.48f) else LumaColors.playerItemBg.copy(alpha = 0.72f) Row( Modifier .fillMaxWidth() @@ -1852,7 +1852,7 @@ private fun LiquidEpisodeItem(episode: EmbyItem, serverUrl: String, selected: Bo val imageUrl = EmbyRepository.buildEpisodeImageUrl(serverUrl, episode, maxWidth = 360) val episodeNumber = episode.indexNumber?.let { stringResource(Res.string.detail_episode_number, it) } ?: stringResource(Res.string.detail_episode_short) - val surface = if (selected) Color(0xFF4D78B8).copy(alpha = 0.48f) else Color(0xFF16243A).copy(alpha = 0.72f) + val surface = if (selected) LumaColors.playerAccent.copy(alpha = 0.48f) else LumaColors.playerItemBg.copy(alpha = 0.72f) Row( Modifier .fillMaxWidth() @@ -1872,7 +1872,7 @@ private fun LiquidEpisodeItem(episode: EmbyItem, serverUrl: String, selected: Bo Modifier .size(width = 112.dp, height = 63.dp) .clip(RoundedCornerShape(10.dp)) - .background(Color(0xFF0B1424)), + .background(LumaColors.playerItemBorder), contentAlignment = Alignment.Center, ) { if (imageUrl != null) { diff --git a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt index d1d161a..611872e 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -32,7 +31,7 @@ fun LiquidAppBottomBar( backdrop: Backdrop?, modifier: Modifier = Modifier, ) { - val active = if (LumaColors.isDark) Color(0xFF0091FF) else Color(0xFF0088FF) + val active = LumaColors.accent val inactive = LumaColors.textSecondary Row( diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt index e95234b..87a8b10 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt @@ -46,6 +46,12 @@ object LumaSpacing { val xxl = 32.dp } +object LumaBorders { + val thin = 0.5.dp + val regular = 0.7.dp + val thick = 1.dp +} + object LumaShapes { val extraSmall = 10.dp val small = 14.dp @@ -57,9 +63,11 @@ object LumaShapes { object LumaTypography { val display: TextUnit = 32.sp val titleLarge: TextUnit = 22.sp + val titleMedium: TextUnit = 20.sp val title: TextUnit = 18.sp val body: TextUnit = 15.sp val secondary: TextUnit = 13.sp + val tiny: TextUnit = 12.sp val caption: TextUnit = 11.sp } @@ -111,9 +119,7 @@ fun rememberLumaLayerBackdrop(): LayerBackdrop? { object LumaColors { val isDark: Boolean get() = AppTheme.isDark - // P1: Cache colors to avoid repeated Color allocation on every access - // These change only when the theme toggles — simple get() is fine since - // Compose recomposition is driven by reading isDark in composition scope. + // ── Semantic colors ── val background: Color get() = if (isDark) Color(0xFF0B0E12) else Color(0xFFF0F3F7) val backgroundElevated: Color @@ -131,6 +137,29 @@ object LumaColors { val divider: Color get() = if (isDark) Color.White.copy(alpha = 0.10f) else Color(0xFF1C2733).copy(alpha = 0.10f) + // ── Status colors (standardized) ── + val online: Color get() = Color(0xFF34C759) + val offline: Color get() = Color(0xFFFF3B30) + val ratingStar: Color get() = Color(0xFFFFC107) + val bestBadgeStart: Color get() = Color(0xFFFFC857) + val bestBadgeEnd: Color get() = Color(0xFFFFB020) + val iconLibGradientStart: Color get() = Color(0xFFFF6B9D) + val iconLibGradientEnd: Color get() = Color(0xFFFF8A65) + val rainbowGradient: List get() = listOf( + Color(0xFFFF5F6D), Color(0xFFFFB347), Color(0xFF36D1DC), + Color(0xFF5B86E5), Color(0xFFB06AB3), + ) + + // ── Player-specific colors (dark-only context) ── + val playerOverlay: Color get() = Color(0xFF07111F) + val playerSurface: Color get() = Color(0xFF101C31) + val playerBorder: Color get() = Color(0xFF243A59) + val playerControlBg: Color get() = Color(0xFF09111F) + val playerAccent: Color get() = Color(0xFF4D78B8) + val playerItemBg: Color get() = Color(0xFF16243A) + val playerItemBorder: Color get() = Color(0xFF0B1424) + + // ── Glass tints ── val clearTint: Color get() = if (isDark) Color(0xFF18202A).copy(alpha = 0.28f) else Color.White.copy(alpha = 0.10f) val regularTint: Color @@ -146,6 +175,9 @@ object LumaColors { val glassShadow: Color get() = if (isDark) Color.Black.copy(alpha = 0.32f) else Color(0xFF344054).copy(alpha = 0.14f) + // ── Settings popup colors ── + val settingsPopupBg: Color get() = if (isDark) Color(0xFF111821) else Color(0xFFF4F7FA) + val settingsPopupBorder: Color get() = if (isDark) Color(0xFF2A3444) else Color(0xFFC8D2DE) } /** Stable frosted fallback for devices where Kyant Backdrop is disabled. */ diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index d40cc7e..ab4e404 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -274,6 +274,19 @@ Today Tomorrow Day after + Mon + Tue + Wed + Thu + Fri + Sat + Sun + %1$d/%2$d + %1$d/%2$d/%3$d + Episodes + Platform: + Updated to Episode %1$d + Not yet aired · Episode %1$d No servers yet Tap the add button to get started No calendar data diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9b8cf7..efa9b3c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -274,6 +274,19 @@ 今天 明天 后天 + 周一 + 周二 + 周三 + 周四 + 周五 + 周六 + 周日 + %1$d月%2$d日 + %1$d年%2$d月%3$d日 + 剧集 + 播出平台: + 已更新至第%1$d集 + 未播出 第%1$d集 暂无服务器 点击右上角添加服务器 暂无日历数据 diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 4df9255..b4951ba 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -1,13 +1,20 @@ - - - \ No newline at end of file + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997..d90f167 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,19 +1,27 @@ - + - + + + + + + + + + + - + + - --> - \ No newline at end of file + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..7a773a1 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + localhost + 10.0.0.0 + 172.16.0.0 + 192.168.0.0 + + .local + .lan + .home + + + + + + + + + + + + + +