diff --git a/.github/workflows/Unsigned Build.yml b/.github/workflows/Unsigned Build.yml index f3fadd6..957cb3a 100644 --- a/.github/workflows/Unsigned Build.yml +++ b/.github/workflows/Unsigned Build.yml @@ -33,6 +33,6 @@ jobs: uses: actions/upload-artifact@v4 with: name: telebox-release-unsigned - path: androidApp/app/build/outputs/apk/release/app-release-unsigned.apk + path: androidApp/app/build/outputs/apk/release/ if-no-files-found: error diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..0713de3 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,288 @@ +--- +name: yumaplayer-uiux-analysis +description: > + Analyzes, replicates, and extends the UI/UX design of YumaPlayer — a FOSS Android + music client built on the Yuma Design System (YDS 2.1): real glassmorphism, segmented + glass preference rows, dynamic Monet/HCT theming extracted from album art, and 120fps + gesture-driven player sheets. Use when (a) building, reviewing, or redesigning Jetpack + Compose UI that should follow a cohesive, music-centric design language; (b) studying + how YumaPlayer achieves calm, depth, low visual noise, and fluid motion; or (c) applying + its component patterns, tokens, or motion rules to another interface. +--- + +# YumaPlayer — UI/UX Design Analysis (Yuma Design System 2.1) + +> Source of truth: `docs/design/YDS.md`, `docs/design/COMPONENT_GUIDE.md`, +> `docs/architecture/DECISIONS.md` (ADR-001/002/006/010), and the Compose +> implementation under `app/src/main/.../ui/theme/*`, `ui/settings/SettingsDimensions.kt`, +> `ui/component/*`, `ui/player/player_0/*`. + +YumaPlayer is a hybrid Android music client (Spotify discovery + YouTube Music library + +Hi-Res/FLAC lossless). Its interface is **not** Material-3-default: it layers a custom +design system (YDS 2.1) on top of Material 3 Expressive, prioritizing music-as-primary +content, depth via glass, breathable whitespace, and tactile micro-interactions. + +--- + +## 1. Design Philosophy + +YDS is opinionated. Five pillars (from `YDS.md` §1–2): + +1. **Calmness** — the UI never overwhelms or distracts from listening. +2. **Depth & Geometry** — translucent `glassBackground` surfaces + 0.5dp hairline + `glassBorder` define layering; composite corner radii fuse separate rows into one + visual group (22dp outer / 5dp inner). +3. **Low Visual Noise** — **dividers are banned** in settings lists and selection sheets. + Separation comes only from gaps (`SegmentGap = 1.5dp`) and structure. +4. **Physicality** — every interactive element gives spring-animated tactile feedback + (whole-card scale to `0.96f` on press). +5. **Color Driven** — the accent palette is extracted at runtime from the *currently + playing* artwork (Dynamic Monet / HCT), driving accents, switch thumbs, and indicators. + +Avoid the "generic monolithic Material 3 template with flat continuous lists." Yuma's +identity is **segmented glass geometry, accented matte icon badges, whitespace, and +tactile motion.** + +--- + +## 2. Visual Language & Character + +- **Segmented glass geometry:** grouped rows are *independent* glass cards, not one shared + container. Group unity comes from matching corner radii + a 1.5dp gap, not borders. +- **Composite corner radii:** outer group corners `22dp`, inner joints `5dp`. +- **Hairline borders:** `0.5dp` translucent outline (`glassBorder`) for crisp edge definition. +- **Matte icon badges:** custom squircle/petal geometry, 32dp / 46dp containers, monochrome + or accent fill — never default Material circular chips. +- **Surface hierarchy (strict 3-tier):** + - *Solid* (`surface`, `surfaceContainer`) — main content where translucency is unnecessary. + - *Static translucent glass* (`glassBackground` + 1dp `glassBorder`, **no blur**) — settings + lists, cards. Modal sheets/dialogs use **opaque** `surfaceContainerHigh`. + - *Backdrop-blur glass* (blur + tonal overlay) — **exclusively** floating overlays: + bottom player bar, floating FAB, modal backdrops. (ADR-006) + +--- + +## 3. Design Tokens (single source of truth) + +All values come from `SettingsDimensions` / `YumaSpacing` / `YumaRadius`. **Never hardcode +dp or hex.** Key tokens (`ui/settings/SettingsDimensions.kt`): + +| Token | Value | Use | +| :-- | :-- | :-- | +| `SegmentedItemGap` | `1.5.dp` | inter-row gap (replaces dividers) | +| `SectionSpacing` | `12.dp` | between independent groups | +| `ScreenHorizontalPadding` | `16.dp` | screen/card horizontal padding | +| `ScreenBottomPadding` | `24.dp` | bottom screen padding | +| `SegmentedCornerLarge` (Outer) | `22.dp` | first/last row outer corners, cards | +| `SegmentedCornerSmall` (Inner) | `5.dp` | inner joints between grouped rows | +| `BottomSheetCornerRadius` | `28.dp` | modal sheets | +| `BottomSheetListCornerRadius` | `24.dp` | inner sheet lists | +| `GlassCornerRadius` | `18.dp` | default glass card | +| `GlassBorderThickness` | `0.5.dp` | hairline outline | +| `SegmentedItemMinHeight` | `72.dp` | preference row min height | +| `SegmentedIconBoxSize` | `46.dp` | icon badge container | +| `RowIconSize` | `32.dp` | standard row icon | +| `LibraryChipHeight` | `36.dp` | filter chips | +| `PressScale` | `0.96f` | press compression | +| `PressDampingRatio` / `Stiffness` | `0.6` / `Medium` | press spring | + +Radii ladder (YDS §4): `SegmentInner 5` · `Small 12` · `Medium 16` · `SegmentOuter 22` · +`SheetList 24` · `Sheet 28` · `Max/Pill 32 / Circle`. + +Hierarchy through scale (YDS §3.1): preference rows `≥72dp`, primary actions `≥56dp`, +standard controls `48×48dp` touch target, icon badges `32/46dp`. + +--- + +## 4. Core Components & The Modifier Chain + +Two primitive modifiers build everything. Both live in `ui/theme/YumaModifiers.kt`. + +### 4.1 `Modifier.yumaGlassCard(...)` — the glass surface +```kotlin +@Composable +fun Modifier.yumaGlassCard( + shape: Shape = RoundedCornerShape(SettingsDimensions.GlassCornerRadius), + backgroundColor: Color = LocalYumaColors.current.glassBackground, + borderColor: Color = LocalYumaColors.current.glassBorder, + strokeWidth: Dp = SettingsDimensions.GlassBorderThickness, + position: YumaSegmentPosition = YumaSegmentPosition.Single, + topAlpha: Float? = null, + bottomAlpha: Float? = null, +): Modifier +``` +Internally: `clip(shape) → background(backgroundColor, shape) → glassBorder(...)`. The hairline +border is a **vertical gradient** (`topAlpha` → `bottomAlpha`) so group joints don't form +blinding double-bright seams. + +### 4.2 `Modifier.yumaClickable(...)` — tactile press +```kotlin +@Composable +fun Modifier.yumaClickable( + enabled: Boolean = true, + pressedScale: Float = SettingsAnimations.PressScale, // 0.96f + onClick: () -> Unit +): Modifier +``` +Spring-scale to `pressedScale` while pressed (`pressSpring()` = `spring(dampingRatio = 0.6, +stiffness = Medium)`); respects `LocalDisableAnimations`. A `yumaCombinedClickable` variant +adds long/double-click + `LocalYumaHaptics` feedback. + +### 4.3 The mandatory modifier order +`.yumaClickable(...)` **MUST precede** `.yumaGlassCard(...)` so the *whole card* scales as one +unit. Putting clickable after (or inside inner containers) scales only the text → broken. +```kotlin +Box( + modifier = modifier + .fillMaxWidth() + .yumaClickable(pressedScale = 0.96f, onClick = onClick) // 1. press + .yumaGlassCard(shape = shape, position = position) // 2. glass + .clip(shape) + .padding(horizontal = 18.dp, vertical = 12.dp) +) { /* Row content: [Badge] Title/Subtitle … [Control/Chevron] */ } +``` + +### 4.4 Segmented group lighting (`YumaSegmentPosition`) +Each row in a group reports its position so its border gradient adapts: +`Single 0.20→0.04` · `First 0.20→0.08` · `Middle 0.08→0.08` · `Last 0.08→0.04`. +Compute with `yumaSegmentPosition(index, count)` + `segmentedSettingsItemShape(index, count)`. + +### 4.5 Standard pieces built from these primitives +`GlassScaffold` (transparent `Scaffold`, transparent top-bar — `GlassScaffold.kt`), +`PreferenceGroup` / `PreferenceEntry` / `SwitchPreference` / `SegmentedPreference` / +`ListPreference` / `EditTextPreference` / `SliderPreference` / `NumberPickerPreference` +(YDS §7), provider chips (sliding 36dp `primary` thumb between YT Music / Spotify), and +selection dialogs (independent segmented cards, `surfaceContainerHigh` modal, checkmark on +selected, `primary.copy(alpha=0.16f)` tint). + +--- + +## 5. Color & Theming (Dynamic Monet / HCT) + +- **`YumaColorScheme`** (`YumaTheme.kt`): `glassBackground`, `glassBorder`, + `cardBackgroundOpaque`, `textPrimary`, `textSecondary`, `rippleColor`. + - Dark: `glassBackground = onSurface@0.08`, `glassBorder = primary@0.10`, + `cardBackgroundOpaque = 0xFF1C1C1E`. + - Light: `glassBackground = White@0.65`, `glassBorder = primary@0.14`. + - Exposed via `LocalYumaColors` (`staticCompositionLocalOf`). Wrap UI in `YumaTheme {}`. +- **Per-track palette** (`PlayerColorExtractor.kt`, ADR-002): `Palette` swatches → a tuned + 6-stop mesh gradient. HSV tuning boosts saturation (×1.25 when sat>0.3), derives hue-shifted + siblings, and **falls back to greyscale stops** for B/W artwork — all on a background + dispatcher (CPU-bound, never Main). `extractedColors` → vibrant / darkMuted / gradient. +- **Rule:** never hardcode `Color(0xFF…)`; pull from `LocalYumaColors.current.*` or + `MaterialTheme.colorScheme.*`. Accent only where it matters (active pills, switch thumbs, + indicators). Maintain WCAG contrast. + +--- + +## 6. Motion & Interaction (fluid, frame-perfect) + +- **Press physics:** whole-card `scale → 0.96f`, `spring(dampingRatio=0.6, stiffness=Medium)`. +- **Telegram-style morphing header** (`YumaMorphingHeader.kt`): scroll-linked `lerp3` + (two-stage lerp) collapses an artist/album poster into a status-bar drop **without image + distortion** — `scale 1→0.44→0`, `cornerPercent 0→32→50`, `translationY` to status-bar + center, and a render-effect **blur only after 70% collapsed** (≤40f). `HeaderType` + controls height ratio + bottom gradient (ARTIST 1.45, ALBUM/PLAYLIST 1.35, SPOTIFY 1.0). +- **Dual-sheet gesture engine** (Lyrics left / Queue right) — `UnifiedPlayerSheetV2.kt`, + ADR-010, 120fps kinematics: + 1. **Draw-phase-only reading** — sheet fractions (`queueFraction`, `lyricsFraction`) are + read **only inside `graphicsLayer { }`**, never in the Composable body (keeps the + Composition tree static during drag → zero re-composition thrash). + 2. **Discrete `BackHandler`** anchored centrally with boolean flags (`isQueueVisible`), + never bound to continuous thresholds. + 3. **Adaptive `CompositingStrategy`** — `Auto` during drag, `Offscreen` only when fully + open (`fraction >= 0.99f`) for edge fades (saves GPU FBO bandwidth mid-gesture). + 4. **Isolated `matchParentSize()` underlay** trims lateral borders via negative layout + translation; content renders as clean siblings. + 5. **Smart Freeze / Pre-warming** — instant 0-frame sheet expansion; off-screen physics + loops + background tasks suspend via `snapshotFlow` to preserve battery/thermals. + > Note: ADR-010 forbids agents from refactoring swipe physics/gesture handlers/sheet layers + > unless explicitly ordered with exact specs. Treat these files as frozen. +- **Backdrop blur is reserved for floating overlays only** (ADR-006) — never on list rows. + +--- + +## 7. Layout Patterns + +- **Full player** (`PlayerLayout.kt`): a custom **single-pass `Layout`** with 3 slots — + `toolbar`, `cover`, `controls`. The cover is a **square** constrained to remaining space, + all slots centered horizontally, controls pinned to bottom, toolbar to top. No nested + scroll/measure thrash. +- **Bottom player bar** — floating, backdrop-blur glass, haptic-driven, dismissible. +- **Library / home** — quick-picks grid, `LibraryChipHeight 36dp` filter chips, immersive + artist posters, scroll-morphing headers. +- **Lyrics** — 3-tier synced (original / romaji / translation), karaoke word-by-word scroll, + on-the-fly AI translation, shareable lyric cards. + +--- + +## 8. Accessibility + +- Touch targets: **48×48dp minimum**; **56dp+** for core player actions; **72dp** min height + for preference rows. +- `contentDescription` for non-text/decorative icons (`null` when purely decorative). +- High-contrast text: `SoftTextShadow` (offset 0,2 / blur 5 / alpha 0.3) protects white text + on bright artwork; dynamic palette tuned for WCAG. +- All components `@ThemePreviews` (light + dark) so both themes stay verifiable. + +--- + +## 9. DO / DON'T (anti-patterns) + +**DO** +- Use `Modifier.yumaGlassCard()` (glass fill + 0.5dp hairline + position-aware lighting) for + every preference row and selection option. +- Put `.yumaClickable(...)` **before** `.yumaGlassCard(...)` for whole-card compression. +- Separate grouped rows with `SegmentGap (1.5dp)`, not `HorizontalDivider`. +- Apply composite radii (22dp outer / 5dp inner) to unify a group. +- Use custom squircle/petal icon badges; reference tokens via `SettingsDimensions` / `LocalYumaColors`. +- Build **100% stateless** components (props + lambda callbacks; no ViewModel/Room/coroutine + launches inside Composables). + +**DON'T** +- Wrap a whole preference group in one opaque container instead of individual segmented rows. +- Apply runtime `BackdropBlur` to list items / preference rows (blur is for floating overlays only). +- Put `.yumaClickable(...)` after `.yumaGlassCard(...)` or inside inner containers (text-only scaling). +- Omit `YumaSegmentPosition` on grouped rows (causes blinding joint lines). +- Hardcode dp / hex colors / raw `TextStyle` / `Shape` outside YDS tokens. +- Use generic circular Material 3 chips; place ViewModel/Room/coroutine calls in Composables; + omit `key` in `LazyColumn`/`LazyRow` items. + +--- + +## 10. How to apply this skill + +When asked to **build, review, or redesign** a screen in the Yuma style: + +1. **Pick the surface tier** — main content = solid; lists/cards = static glass; floating + player/FAB/modal backdrop = blur glass. No dividers anywhere. +2. **Start from tokens** — pull every dp/radius/color from `SettingsDimensions` / `LocalYumaColors`. +3. **Compose with the primitives** — `Box.modifier.yumaClickable(...).yumaGlassCard(position=...)`. + Compute `position = yumaSegmentPosition(index, count)` and `shape = segmentedSettingsItemShape(...)`. +4. **Keep components stateless** — props + `onClick` lambdas; emit intents, don't fetch. +5. **Add motion** — press scale `0.96f` spring; read continuous fractions inside `graphicsLayer {}`. +6. **Theme dynamically** — derive accents from artwork via `PlayerColorExtractor`; respect dark/light. +7. **Verify a11y** — 48/56/72dp targets, `contentDescription`, `@ThemePreviews` light+dark. +8. **Self-check against §9** before shipping. + +When **analyzing** an unfamiliar FOSS UI, mirror §1–§8: identify the philosophy, surface +hierarchy, token discipline, component primitives, theming strategy, motion model, layout +system, and accessibility — then summarize strengths, inconsistencies, and reusable patterns. + +--- + +## 11. Source Map (for deeper reads) + +| Concern | File | +| :-- | :-- | +| Design spec | `docs/design/YDS.md`, `docs/design/COMPONENT_GUIDE.md` | +| Design tokens | `app/src/main/.../ui/settings/SettingsDimensions.kt` | +| Glass + click modifiers | `app/src/main/.../ui/theme/YumaModifiers.kt` | +| Soft shadows / text shadow | `app/src/main/.../ui/theme/ModifierExt.kt` | +| Color scheme + theme | `app/src/main/.../ui/theme/YumaTheme.kt` | +| Dynamic palette extraction | `app/src/main/.../ui/theme/PlayerColorExtractor.kt` | +| Glass scaffold | `app/src/main/.../ui/component/GlassScaffold.kt` | +| Morphing artist/album header | `app/src/main/.../ui/component/YumaMorphingHeader.kt` | +| Player layout (single-pass) | `app/src/main/.../ui/player/player_0/PlayerLayout.kt` | +| Dual-sheet gesture engine | `app/src/main/.../ui/player/player_0/UnifiedPlayerSheetV2.kt` | +| Architecture / ADRs | `docs/architecture/DECISIONS.md` (ADR-001/002/006/010) | diff --git a/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/FileCard.kt b/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/FileCard.kt index 8a6a292..d97a024 100644 --- a/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/FileCard.kt +++ b/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/FileCard.kt @@ -1,6 +1,7 @@ package com.telebox.app.ui.dashboard import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box @@ -21,6 +22,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -41,6 +44,7 @@ fun FileCard( isSelected: Boolean, selecting: Boolean, thumbnail: String? = null, + onRequestThumbnail: () -> Unit = {}, onClick: () -> Unit, onLongClick: () -> Unit, onToggleSelection: () -> Unit, @@ -49,6 +53,8 @@ fun FileCard( val scheme = MaterialTheme.colorScheme val isFolder = file.type == ItemType.FOLDER + LaunchedEffect(file.id) { onRequestThumbnail() } + Card( modifier = Modifier .fillMaxWidth() @@ -66,12 +72,22 @@ fun FileCard( contentAlignment = Alignment.Center ) { if (thumbnail != null) { - AsyncImage( - model = thumbnail, - contentDescription = file.name, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) + val bitmap = remember(thumbnail) { base64ToImageBitmap(thumbnail) } + if (bitmap != null) { + Image( + bitmap = bitmap, + contentDescription = file.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } else { + AsyncImage( + model = thumbnail, + contentDescription = file.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } } else if (isFolder) { FolderTypeIcon(size = FileIconSize.LG) } else { diff --git a/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/FileExplorer.kt b/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/FileExplorer.kt index f250468..a810267 100644 --- a/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/FileExplorer.kt +++ b/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/FileExplorer.kt @@ -47,6 +47,8 @@ fun FileExplorer( sortDirection: SortDirection, compact: Boolean, librarySection: LibrarySection = LibrarySection.ALL, + thumbnails: Map = emptyMap(), + onRequestThumbnail: (TelegramFile) -> Unit = {}, onSort: (SortField) -> Unit, onOpen: (TelegramFile) -> Unit, onToggleSelection: (Long) -> Unit, @@ -133,6 +135,8 @@ fun FileExplorer( file = file, isSelected = file.id in selectedIds, selecting = selecting, + thumbnail = thumbnails[file.id], + onRequestThumbnail = { onRequestThumbnail(file) }, onClick = { if (selecting) onToggleSelection(file.id) else onOpen(file) }, @@ -158,6 +162,8 @@ fun FileExplorer( selected = file.id in selectedIds, compact = compact, selecting = selecting, + thumbnail = thumbnails[file.id], + onRequestThumbnail = { onRequestThumbnail(file) }, onClick = { if (selecting) onToggleSelection(file.id) else onOpen(file) }, diff --git a/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/FileListItem.kt b/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/FileListItem.kt index 28aaa0a..d61222b 100644 --- a/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/FileListItem.kt +++ b/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/FileListItem.kt @@ -1,6 +1,7 @@ package com.telebox.app.ui.dashboard import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box @@ -11,6 +12,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.outlined.MoreVert @@ -19,11 +21,15 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage import com.telebox.app.data.ItemType import com.telebox.app.data.TelegramFile import com.telebox.app.ui.components.FileIconSize @@ -37,12 +43,17 @@ fun FileListItem( selected: Boolean, compact: Boolean, selecting: Boolean, + thumbnail: String? = null, + onRequestThumbnail: () -> Unit = {}, onClick: () -> Unit, onLongClick: () -> Unit, onMore: () -> Unit ) { val scheme = MaterialTheme.colorScheme val isFolder = file.type == ItemType.FOLDER + + LaunchedEffect(file.id) { onRequestThumbnail() } + val meta = buildString { append(file.sizeStr) if (!compact && !file.createdAt.isNullOrBlank()) { @@ -88,6 +99,23 @@ fun FileListItem( } } else if (isFolder) { FolderTypeIcon(size = FileIconSize.MD) + } else if (thumbnail != null) { + val bitmap = remember(thumbnail) { base64ToImageBitmap(thumbnail) } + if (bitmap != null) { + Image( + bitmap = bitmap, + contentDescription = file.name, + contentScale = ContentScale.Crop, + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)) + ) + } else { + AsyncImage( + model = thumbnail, + contentDescription = file.name, + contentScale = ContentScale.Crop, + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)) + ) + } } else { FileTypeIcon(filename = file.name, size = FileIconSize.MD) } diff --git a/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/MediaPlayer.kt b/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/MediaPlayer.kt index 2fa0e20..45ff4f5 100644 --- a/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/MediaPlayer.kt +++ b/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/MediaPlayer.kt @@ -2,6 +2,7 @@ package com.telebox.app.ui.dashboard import android.view.ViewGroup import android.widget.FrameLayout +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -9,6 +10,8 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,6 +26,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Audiotrack import androidx.compose.material.icons.outlined.Close @@ -32,10 +36,7 @@ import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.SkipNext import androidx.compose.material.icons.outlined.SkipPrevious import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults @@ -46,6 +47,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -53,11 +55,12 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.media3.common.MediaItem @@ -66,12 +69,16 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView import com.telebox.app.data.TelegramFile +import com.telebox.app.ui.components.rememberFileTypeInfo +import com.telebox.app.ui.theme.yumaClickable import com.telebox.app.util.isAudioFile import com.telebox.app.util.isVideoFile import com.telebox.app.util.mediaPlaybackUri import com.telebox.app.util.mimeTypeForFile import kotlinx.coroutines.delay +private val PLAYBACK_SPEEDS = floatArrayOf(1f, 1.25f, 1.5f, 2f, 0.75f) + @kotlin.OptIn(UnstableApi::class) @Composable fun MediaPlayerDialog( @@ -89,14 +96,17 @@ fun MediaPlayerDialog( val context = LocalContext.current val isVideo = isVideoFile(file.name) val isAudio = isAudioFile(file.name) + val accent = rememberFileTypeInfo(file.name).color val source = streamUrl var isPlaying by remember { mutableStateOf(false) } var positionMs by remember { mutableLongStateOf(0L) } var durationMs by remember { mutableLongStateOf(0L) } + var bufferedMs by remember { mutableLongStateOf(0L) } var sliderPosition by remember { mutableFloatStateOf(0f) } var userSeeking by remember { mutableStateOf(false) } var playerError by remember { mutableStateOf(null) } + var speedIndex by remember { mutableIntStateOf(0) } val player = remember { ExoPlayer.Builder(context).build().apply { @@ -107,16 +117,12 @@ fun MediaPlayerDialog( DisposableEffect(player) { val listener = object : Player.Listener { - override fun onIsPlayingChanged(playing: Boolean) { - isPlaying = playing - } - + override fun onIsPlayingChanged(playing: Boolean) { isPlaying = playing } override fun onPlaybackStateChanged(playbackState: Int) { durationMs = player.duration.coerceAtLeast(0L) } - - override fun onPlayerError(error: androidx.media3.common.PlaybackException) { - playerError = error.message ?: "Playback failed" + override fun onPlayerError(e: androidx.media3.common.PlaybackException) { + playerError = e.message ?: "Playback failed" } } player.addListener(listener) @@ -146,6 +152,7 @@ fun MediaPlayerDialog( if (!userSeeking) { positionMs = player.currentPosition.coerceAtLeast(0L) durationMs = player.duration.coerceAtLeast(0L) + bufferedMs = player.bufferedPosition.coerceAtLeast(0L) val dur = durationMs sliderPosition = if (dur > 0L) (positionMs.toFloat() / dur.toFloat()).coerceIn(0f, 1f) else 0f } @@ -153,198 +160,121 @@ fun MediaPlayerDialog( } } + val buffering = player.playbackState == Player.STATE_BUFFERING && isPlaying + val progress = sliderPosition + val buffered = if (durationMs > 0L) (bufferedMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) else 0f + val speed = PLAYBACK_SPEEDS[speedIndex] + + val onTogglePlay = { if (player.isPlaying) player.pause() else player.play() } + val onSeek: (Float) -> Unit = { userSeeking = true; sliderPosition = it } + val onSeekFinished: (Float) -> Unit = { + val dur = durationMs + if (dur > 0L) player.seekTo((it * dur).toLong()) + userSeeking = false + } + val onCycleSpeed = { + speedIndex = (speedIndex + 1) % PLAYBACK_SPEEDS.size + player.setPlaybackSpeed(PLAYBACK_SPEEDS[speedIndex]) + } + Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.94f)) + .background(Color.Black.copy(alpha = 0.96f)) .systemBarsPadding() ) { - FilledTonalIconButton( - onClick = onClose, - modifier = Modifier - .align(Alignment.TopEnd) + // Subtle tonal depth wash derived from the file's accent colour. + Box( + Modifier + .fillMaxSize() + .background( + Brush.radialGradient( + colors = listOf(accent.copy(alpha = 0.16f), Color.Transparent), + radius = 900f + ) + ) + ) + + // Top bar + Row( + Modifier + .fillMaxWidth() .padding(12.dp), - colors = IconButtonDefaults.filledTonalIconButtonColors( - containerColor = scheme.surface.copy(alpha = 0.28f), - contentColor = Color.White - ) + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Icon(Icons.Outlined.Close, contentDescription = "Close") + PlayerGlassButton(onClick = onClose, size = 44.dp, iconSize = 22.dp, + containerColor = scheme.surface.copy(alpha = 0.28f), contentColor = Color.White, + icon = Icons.Outlined.Close) + if (totalItems > 1) { + Box( + Modifier + .clip(RoundedCornerShape(999.dp)) + .background(scheme.surface.copy(alpha = 0.28f)) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Text("${currentIndex + 1}/$totalItems", color = Color.White, + style = MaterialTheme.typography.labelMedium) + } + } } Column( - modifier = Modifier + Modifier .align(Alignment.Center) .fillMaxWidth() .padding(horizontal = 20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { when { - loading || source == null && error == null -> Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - CircularProgressIndicator( - color = scheme.primary, - modifier = Modifier.size(48.dp), - strokeWidth = 4.dp - ) - Text( - "Preparing stream...", - color = Color.White, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 16.dp) - ) + loading || (source == null && error == null) -> Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(color = accent, modifier = Modifier.size(48.dp), strokeWidth = 4.dp) + Text("Preparing stream...", color = Color.White, style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 16.dp)) } - - error != null || playerError != null -> Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - Icons.Outlined.ErrorOutline, - contentDescription = null, - tint = scheme.error, - modifier = Modifier.size(48.dp) - ) - Text( - playerError ?: error ?: "Unable to play media", - color = Color.White, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 16.dp) - ) + error != null || playerError != null -> Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Outlined.ErrorOutline, contentDescription = null, tint = scheme.error, modifier = Modifier.size(48.dp)) + Text(playerError ?: error ?: "Unable to play media", color = Color.White, + style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 16.dp)) } - - isVideo -> { - Surface( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f), - shape = MaterialTheme.shapes.extraLarge, - color = Color.Black - ) { - AndroidView( - factory = { ctx -> - PlayerView(ctx).apply { - useController = false - this.player = player - layoutParams = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - } - }, - update = { view -> view.player = player }, - modifier = Modifier.fillMaxSize() - ) - } - } - + isVideo -> VideoPlayer( + player = player, + isPlaying = isPlaying, + buffering = buffering, + progress = progress, + buffered = buffered, + positionMs = positionMs, + durationMs = durationMs, + speed = speed, + accent = accent, + onGlass = Color.White, + onTogglePlay = onTogglePlay, + onSeek = onSeek, + onSeekFinished = onSeekFinished, + onPrev = onPrev, + onNext = onNext, + onCycleSpeed = onCycleSpeed + ) isAudio -> AudioNowPlaying( fileName = file.name, + subtitle = "Audio track • ${file.sizeStr}", isPlaying = isPlaying, - sliderPosition = sliderPosition, + progress = progress, + buffered = buffered, positionMs = positionMs, durationMs = durationMs, - onSeek = { value -> - userSeeking = true - sliderPosition = value - }, - onSeekFinished = { - val dur = durationMs - if (dur > 0L) player.seekTo((sliderPosition * dur).toLong()) - userSeeking = false - }, - onTogglePlay = { - if (player.isPlaying) player.pause() else player.play() - }, + speed = speed, + accent = accent, + onGlass = Color.White, + onSeek = onSeek, + onSeekFinished = onSeekFinished, + onTogglePlay = onTogglePlay, onPrev = onPrev, - onNext = onNext + onNext = onNext, + onCycleSpeed = onCycleSpeed ) - else -> Text("Unsupported media type", color = Color.White) } - - if (!isAudio) { - Spacer(Modifier.height(20.dp)) - Text( - file.name, - color = Color.White, - style = MaterialTheme.typography.titleMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Text( - text = if (totalItems > 0) { - "Streaming from Telegram Drive • ${currentIndex + 1}/$totalItems" - } else { - "Streaming from Telegram Drive" - }, - color = Color.White.copy(alpha = 0.55f), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 6.dp) - ) - if (isVideo) { - Slider( - value = sliderPosition, - onValueChange = { - userSeeking = true - sliderPosition = it - }, - onValueChangeFinished = { - val dur = durationMs - if (dur > 0L) player.seekTo((sliderPosition * dur).toLong()) - userSeeking = false - }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - colors = SliderDefaults.colors( - thumbColor = scheme.primary, - activeTrackColor = scheme.primary, - inactiveTrackColor = Color.White.copy(alpha = 0.18f) - ) - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(formatClock(positionMs), color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.labelSmall) - Text(formatClock(durationMs), color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.labelSmall) - } - Row( - modifier = Modifier.padding(top = 12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - FilledTonalIconButton(onClick = onPrev, modifier = Modifier.size(56.dp)) { - Icon(Icons.Outlined.SkipPrevious, contentDescription = "Previous") - } - FilledIconButton( - onClick = { if (player.isPlaying) player.pause() else player.play() }, - modifier = Modifier.size(72.dp) - ) { - Icon( - if (isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow, - contentDescription = if (isPlaying) "Pause" else "Play", - modifier = Modifier.size(36.dp) - ) - } - FilledTonalIconButton(onClick = onNext, modifier = Modifier.size(56.dp)) { - Icon(Icons.Outlined.SkipNext, contentDescription = "Next") - } - } - } - } else { - Text( - text = if (totalItems > 0) { - "Streaming from Telegram Drive • ${currentIndex + 1}/$totalItems" - } else { - "Streaming from Telegram Drive" - }, - color = Color.White.copy(alpha = 0.55f), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 8.dp) - ) - } } } } @@ -352,114 +282,275 @@ fun MediaPlayerDialog( @Composable private fun AudioNowPlaying( fileName: String, + subtitle: String, isPlaying: Boolean, - sliderPosition: Float, + progress: Float, + buffered: Float, positionMs: Long, durationMs: Long, + speed: Float, + accent: Color, + onGlass: Color, onSeek: (Float) -> Unit, - onSeekFinished: () -> Unit, + onSeekFinished: (Float) -> Unit, onTogglePlay: () -> Unit, onPrev: () -> Unit, - onNext: () -> Unit + onNext: () -> Unit, + onCycleSpeed: () -> Unit ) { - val scheme = MaterialTheme.colorScheme - val spin = rememberInfiniteTransition(label = "disc") - val angle by spin.animateFloat( - initialValue = 0f, - targetValue = 360f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = 12000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ), - label = "disc-angle" - ) - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .size(220.dp) - .clip(CircleShape) - .background( - Brush.radialGradient( - colors = listOf(scheme.primaryContainer, scheme.primary, scheme.tertiary) - ) - ) - .rotate(if (isPlaying) angle else 0f), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .size(168.dp) - .clip(CircleShape) - .background(scheme.surfaceContainerHigh), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Outlined.Audiotrack, - contentDescription = null, - tint = scheme.primary, - modifier = Modifier.size(72.dp) - ) - } - } + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + AudioCover(accent = accent, isPlaying = isPlaying, seed = fileName, modifier = Modifier.size(240.dp)) Spacer(Modifier.height(28.dp)) Text( fileName, color = Color.White, - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.titleLarge, maxLines = 2, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.basicMarquee() ) - Spacer(Modifier.height(20.dp)) - Slider( - value = sliderPosition, - onValueChange = onSeek, - onValueChangeFinished = onSeekFinished, - modifier = Modifier.fillMaxWidth(), - colors = SliderDefaults.colors( - thumbColor = scheme.primary, - activeTrackColor = scheme.primary, - inactiveTrackColor = Color.White.copy(alpha = 0.18f) - ) - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { + Spacer(Modifier.height(6.dp)) + Text(subtitle, color = Color.White.copy(alpha = 0.62f), style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(26.dp)) + PlayerSeekBar(progress, buffered, onSeek, onSeekFinished, accent, onGlass, Modifier.fillMaxWidth()) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text(formatClock(positionMs), color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.labelSmall) Text(formatClock(durationMs), color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.labelSmall) } - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(14.dp)) Row( + Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally) ) { - FilledTonalIconButton( - onClick = onPrev, - modifier = Modifier.size(56.dp) - ) { - Icon(Icons.Outlined.SkipPrevious, contentDescription = "Previous", modifier = Modifier.size(28.dp)) - } - FilledIconButton( - onClick = onTogglePlay, - modifier = Modifier.size(76.dp) - ) { - Icon( - if (isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow, - contentDescription = if (isPlaying) "Pause" else "Play", - modifier = Modifier.size(40.dp) + SpeedChip(speed = speed, onClick = onCycleSpeed) + PlayerGlassButton(onClick = onPrev, size = 60.dp, iconSize = 30.dp, + containerColor = Color.White.copy(alpha = 0.14f), contentColor = Color.White, + icon = Icons.Outlined.SkipPrevious) + PlayerGlassButton(onClick = onTogglePlay, size = 78.dp, iconSize = 40.dp, + containerColor = accent, contentColor = Color.Black, + icon = if (isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow) + PlayerGlassButton(onClick = onNext, size = 60.dp, iconSize = 30.dp, + containerColor = Color.White.copy(alpha = 0.14f), contentColor = Color.White, + icon = Icons.Outlined.SkipNext) + Spacer(Modifier.width(0.dp)) + } + } +} + +@Composable +private fun VideoPlayer( + player: Player, + isPlaying: Boolean, + buffering: Boolean, + progress: Float, + buffered: Float, + positionMs: Long, + durationMs: Long, + speed: Float, + accent: Color, + onGlass: Color, + onTogglePlay: () -> Unit, + onSeek: (Float) -> Unit, + onSeekFinished: (Float) -> Unit, + onPrev: () -> Unit, + onNext: () -> Unit, + onCycleSpeed: () -> Unit +) { + var controls by remember { mutableStateOf(true) } + LaunchedEffect(isPlaying, controls) { + if (isPlaying && controls) { + delay(3200) + controls = false + } + } + + Box( + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(28.dp)) + .clickable { controls = !controls } + ) { + Surface(color = Color.Black, shape = RoundedCornerShape(28.dp)) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + useController = false + this.player = player + layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + } + }, + update = { view -> view.player = player }, + modifier = Modifier.fillMaxSize() + ) + } + + if (buffering) { + CircularProgressIndicator( + color = Color.White, modifier = Modifier.align(Alignment.Center).size(46.dp), strokeWidth = 4.dp + ) + } + + AnimatedVisibility(controls, Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize()) { + Box( + Modifier.fillMaxSize().background( + Brush.verticalGradient( + 0.0f to Color.Transparent, 0.75f to Color.Black.copy(alpha = 0.55f), + 1.0f to Color.Black.copy(alpha = 0.8f) + ) + ) ) - } - FilledTonalIconButton( - onClick = onNext, - modifier = Modifier.size(56.dp) - ) { - Icon(Icons.Outlined.SkipNext, contentDescription = "Next", modifier = Modifier.size(28.dp)) + if (!isPlaying) { + PlayerGlassButton( + onClick = onTogglePlay, size = 74.dp, iconSize = 38.dp, + containerColor = Color.White.copy(alpha = 0.18f), contentColor = Color.White, + icon = Icons.Outlined.PlayArrow, + modifier = Modifier.align(Alignment.Center) + ) + } + Column( + Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + PlayerSeekBar(progress, buffered, onSeek, onSeekFinished, Color.White, onGlass, Modifier.fillMaxWidth()) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(formatClock(positionMs), color = Color.White.copy(alpha = 0.8f), style = MaterialTheme.typography.labelSmall) + SpeedChip(speed = speed, onClick = onCycleSpeed) + Text(formatClock(durationMs), color = Color.White.copy(alpha = 0.8f), style = MaterialTheme.typography.labelSmall) + } + Spacer(Modifier.height(10.dp)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + PlayerGlassButton(onClick = onPrev, size = 58.dp, iconSize = 30.dp, + containerColor = Color.White.copy(alpha = 0.16f), contentColor = Color.White, + icon = Icons.Outlined.SkipPrevious) + PlayerGlassButton(onClick = onTogglePlay, size = 72.dp, iconSize = 38.dp, + containerColor = Color.White, contentColor = Color.Black, + icon = if (isPlaying) Icons.Outlined.Pause else Icons.Outlined.PlayArrow) + PlayerGlassButton(onClick = onNext, size = 58.dp, iconSize = 30.dp, + containerColor = Color.White.copy(alpha = 0.16f), contentColor = Color.White, + icon = Icons.Outlined.SkipNext) + } + } } } - Spacer(Modifier.width(1.dp)) + } +} + +@Composable +private fun AudioCover(accent: Color, isPlaying: Boolean, seed: String, modifier: Modifier = Modifier) { + val (c1, c2) = remember(seed) { + val h = (seed.hashCode().let { if (it < 0) -it else it } % 360) + Color.hsv(h.toFloat(), 0.62f, 0.95f) to Color.hsv(((h + 38) % 360).toFloat(), 0.72f, 0.66f) + } + Box( + modifier + .clip(RoundedCornerShape(24.dp)) + .background(Brush.linearGradient(listOf(c1, c2))), + contentAlignment = Alignment.Center + ) { + Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to Color.White.copy(0.14f), 1f to Color.Transparent))) + if (isPlaying) { + EqualizerBars(isPlaying = true, color = Color.White, modifier = Modifier.size(120.dp, 64.dp)) + } else { + Icon(Icons.Outlined.Audiotrack, contentDescription = null, tint = Color.White.copy(alpha = 0.92f), + modifier = Modifier.size(72.dp)) + } + } +} + +@Composable +private fun EqualizerBars(isPlaying: Boolean, color: Color, modifier: Modifier = Modifier) { + val transition = rememberInfiniteTransition(label = "eq") + Row(modifier, horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.Bottom) { + repeat(4) { i -> + val target by transition.animateFloat( + initialValue = 8f, + targetValue = if (isPlaying) (16f + i * 10) else 8f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 380 + i * 130, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "eq-$i" + ) + Box(Modifier.width(6.dp).height(target.dp).clip(CircleShape).background(color)) + } + } +} + +@Composable +private fun PlayerSeekBar( + progress: Float, + buffered: Float, + onSeek: (Float) -> Unit, + onSeekFinished: (Float) -> Unit, + accent: Color, + onGlass: Color, + modifier: Modifier = Modifier +) { + var dragging by remember { mutableStateOf(false) } + var dragFrac by remember { mutableFloatStateOf(0f) } + val shown = if (dragging) dragFrac else progress + Box(modifier.fillMaxWidth().height(28.dp), contentAlignment = Alignment.CenterStart) { + Box(Modifier.fillMaxWidth().height(5.dp).clip(CircleShape).background(onGlass.copy(alpha = 0.22f))) { + Box(Modifier.fillMaxWidth(buffered.coerceIn(0f, 1f)).height(5.dp).background(onGlass.copy(alpha = 0.4f))) + Box(Modifier.fillMaxWidth(shown.coerceIn(0f, 1f)).height(5.dp).background(accent)) + } + Slider( + value = shown.coerceIn(0f, 1f), + onValueChange = { dragging = true; dragFrac = it; onSeek(it) }, + onValueChangeFinished = { dragging = false; onSeekFinished(dragFrac) }, + colors = SliderDefaults.colors( + thumbColor = accent, + activeTrackColor = Color.Transparent, + inactiveTrackColor = Color.Transparent, + disabledThumbColor = accent, + disabledActiveTrackColor = Color.Transparent, + disabledInactiveTrackColor = Color.Transparent + ), + modifier = Modifier.fillMaxWidth().height(28.dp) + ) + } +} + +@Composable +private fun SpeedChip(speed: Float, onClick: () -> Unit) { + Box( + Modifier + .yumaClickable(onClick = onClick, haptic = false) + .clip(RoundedCornerShape(999.dp)) + .background(Color.White.copy(alpha = 0.16f)) + .padding(horizontal = 12.dp, vertical = 5.dp) + ) { + Text("${if (speed % 1f == 0f) speed.toInt() else speed}x", color = Color.White, + style = MaterialTheme.typography.labelMedium) + } +} + +@Composable +private fun PlayerGlassButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: Dp = 64.dp, + iconSize: Dp = 30.dp, + containerColor: Color, + contentColor: Color, + icon: ImageVector +) { + Box( + modifier + .yumaClickable(onClick = onClick) + .size(size) + .clip(CircleShape) + .background(containerColor), + contentAlignment = Alignment.Center + ) { + Icon(icon, contentDescription = null, tint = contentColor, modifier = Modifier.size(iconSize)) } } diff --git a/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/PreviewModal.kt b/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/PreviewModal.kt index 1ff8127..3a37eff 100644 --- a/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/PreviewModal.kt +++ b/androidApp/app/src/main/java/com/telebox/app/ui/dashboard/PreviewModal.kt @@ -1,5 +1,8 @@ package com.telebox.app.ui.dashboard +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -16,15 +19,22 @@ import androidx.compose.material.icons.outlined.ChevronLeft import androidx.compose.material.icons.outlined.ChevronRight import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.InsertDriveFile +import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -34,6 +44,16 @@ import com.telebox.app.data.TelegramFile import com.telebox.app.ui.theme.TeleBoxTheme import com.telebox.app.util.isImageFile +/** Decode a `data:image/...;base64,...` URL into an [ImageBitmap]; null for any other input. */ +internal fun base64ToImageBitmap(dataUrl: String): ImageBitmap? { + if (!dataUrl.startsWith("data:image", ignoreCase = true)) return null + val comma = dataUrl.indexOf(',') + if (comma < 0) return null + val bytes = runCatching { Base64.decode(dataUrl.substring(comma + 1), Base64.DEFAULT) }.getOrNull() ?: return null + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null + return bitmap.asImageBitmap() +} + @Composable fun PreviewModal( file: TelegramFile, @@ -47,10 +67,12 @@ fun PreviewModal( onPrev: () -> Unit ) { val colors = TeleBoxTheme.colors + var imageError by remember(src) { mutableStateOf(false) } + Box( modifier = Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.9f)) + .background(Color.Black.copy(alpha = 0.92f)) .clickable(onClick = onClose) .systemBarsPadding() .padding(12.dp) @@ -61,27 +83,21 @@ fun PreviewModal( .align(Alignment.CenterStart) .clip(CircleShape) .background(Color.Black.copy(alpha = 0.6f)) - ) { - Icon(Icons.Outlined.ChevronLeft, contentDescription = "Previous", tint = Color.White, modifier = Modifier.size(24.dp)) - } + ) { Icon(Icons.Outlined.ChevronLeft, contentDescription = "Previous", tint = Color.White, modifier = Modifier.size(24.dp)) } IconButton( onClick = onNext, modifier = Modifier .align(Alignment.CenterEnd) .clip(CircleShape) .background(Color.Black.copy(alpha = 0.6f)) - ) { - Icon(Icons.Outlined.ChevronRight, contentDescription = "Next", tint = Color.White, modifier = Modifier.size(24.dp)) - } + ) { Icon(Icons.Outlined.ChevronRight, contentDescription = "Next", tint = Color.White, modifier = Modifier.size(24.dp)) } IconButton( onClick = onClose, modifier = Modifier .align(Alignment.TopEnd) .clip(CircleShape) .background(Color.Black.copy(alpha = 0.6f)) - ) { - Icon(Icons.Outlined.Close, contentDescription = "Close", tint = Color.White) - } + ) { Icon(Icons.Outlined.Close, contentDescription = "Close", tint = Color.White) } Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { when { @@ -100,12 +116,25 @@ fun PreviewModal( Text(error, color = colors.danger, fontSize = 14.sp) } src != null && isImageFile(file.name) -> { - AsyncImage( - model = src, - contentDescription = file.name, - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxWidth().padding(48.dp) - ) + val bitmap = remember(src) { base64ToImageBitmap(src) } + if (bitmap != null) { + Image( + bitmap = bitmap, + contentDescription = file.name, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 48.dp) + ) + } else if (!imageError) { + AsyncImage( + model = src, + contentDescription = file.name, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 48.dp), + onError = { imageError = true } + ) + } else { + ImageErrorRetry(onRetry = { imageError = false }) + } } else -> Column( modifier = Modifier @@ -129,3 +158,21 @@ fun PreviewModal( ) } } + +@Composable +private fun ImageErrorRetry(onRetry: () -> Unit) { + val colors = TeleBoxTheme.colors + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Outlined.Refresh, contentDescription = null, tint = Color.White, modifier = Modifier.size(48.dp)) + Text("Couldn't load image", color = Color.White, modifier = Modifier.padding(top = 12.dp)) + Box( + Modifier + .clickable(onClick = onRetry) + .clip(RoundedCornerShape(999.dp)) + .background(colors.primary.copy(alpha = 0.2f)) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text("Retry", color = colors.primary, fontWeight = FontWeight.Medium) + } + } +} diff --git a/androidApp/app/src/main/java/com/telebox/app/ui/screens/DashboardScreen.kt b/androidApp/app/src/main/java/com/telebox/app/ui/screens/DashboardScreen.kt index 2562654..59132c5 100644 --- a/androidApp/app/src/main/java/com/telebox/app/ui/screens/DashboardScreen.kt +++ b/androidApp/app/src/main/java/com/telebox/app/ui/screens/DashboardScreen.kt @@ -316,6 +316,8 @@ private fun DashboardScaffold( sortDirection = state.sortDirection, compact = compact, librarySection = state.librarySection, + thumbnails = state.thumbnails, + onRequestThumbnail = viewModel::requestThumbnail, onSort = viewModel::setSort, onOpen = { file -> if (file.type == ItemType.FOLDER) viewModel.setActiveFolder(file.id) diff --git a/androidApp/app/src/main/java/com/telebox/app/ui/theme/YumaDesign.kt b/androidApp/app/src/main/java/com/telebox/app/ui/theme/YumaDesign.kt new file mode 100644 index 0000000..ad1863d --- /dev/null +++ b/androidApp/app/src/main/java/com/telebox/app/ui/theme/YumaDesign.kt @@ -0,0 +1,193 @@ +package com.telebox.app.ui.theme + +import android.view.HapticFeedbackConstants +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.border +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.material3.MaterialTheme + +/** + * Yuma Design System (YDS 2.1) primitives adapted for TeleBox. + * + * These mirror the building blocks analysed in YumaPlayer (see SKILL.md): + * - segmented glass surfaces with position-aware hairline lighting + * - tactile press-scale clickables (0.96f spring) with light haptics + * - composite corner radii (22dp outer / 5dp inner) and a 1.5dp segment gap + * + * The system is intentionally additive: it reads from TeleBox's existing + * [TeleBoxTheme] + Material 3 surfaces and never mutates them. + */ + +object YumaTokens { + val SegmentGap = 1.5.dp + val SegmentOuter = 22.dp + val SegmentInner = 5.dp + val SheetRadius = 28.dp + val CardRadius = 18.dp + val PressScale = 0.96f + val TouchTarget = 48.dp + val GlassBorderThickness = 0.5.dp +} + +enum class YumaSegmentPosition { Single, First, Middle, Last } + +fun yumaSegmentPosition(index: Int, count: Int): YumaSegmentPosition = when { + count <= 1 -> YumaSegmentPosition.Single + index == 0 -> YumaSegmentPosition.First + index == count - 1 -> YumaSegmentPosition.Last + else -> YumaSegmentPosition.Middle +} + +fun yumaSegmentAlphas(position: YumaSegmentPosition): Pair = when (position) { + YumaSegmentPosition.Single -> 0.20f to 0.04f + YumaSegmentPosition.First -> 0.20f to 0.08f + YumaSegmentPosition.Middle -> 0.08f to 0.08f + YumaSegmentPosition.Last -> 0.08f to 0.04f +} + +/** Composite corner shape that fuses adjacent rows into one visual group. */ +fun segmentedItemShape(index: Int, count: Int): Shape { + return when (yumaSegmentPosition(index, count)) { + YumaSegmentPosition.Single -> RoundedCornerShape(YumaTokens.SegmentOuter) + YumaSegmentPosition.First -> RoundedCornerShape( + topStart = YumaTokens.SegmentOuter, topEnd = YumaTokens.SegmentOuter, + bottomStart = YumaTokens.SegmentInner, bottomEnd = YumaTokens.SegmentInner + ) + YumaSegmentPosition.Middle -> RoundedCornerShape(YumaTokens.SegmentInner) + YumaSegmentPosition.Last -> RoundedCornerShape( + topStart = YumaTokens.SegmentInner, topEnd = YumaTokens.SegmentInner, + bottomStart = YumaTokens.SegmentOuter, bottomEnd = YumaTokens.SegmentOuter + ) + } +} + +data class YumaGlassColors( + val background: Color, + val border: Color, + val onGlass: Color +) + +@Composable +fun yumaGlassColors(): YumaGlassColors { + val colors = TeleBoxTheme.colors + val scheme = MaterialTheme.colorScheme + val isDark = scheme.background.luminance() < 0.5f + return YumaGlassColors( + background = if (isDark) colors.surface.copy(alpha = 0.72f) else Color.White.copy(alpha = 0.66f), + border = colors.primary.copy(alpha = if (isDark) 0.16f else 0.20f), + onGlass = scheme.onSurface + ) +} + +/** Hairline gradient border: top->bottom light falloff prevents blinding joint seams. */ +fun Modifier.glassBorder( + shape: Shape, + strokeWidth: Dp = YumaTokens.GlassBorderThickness, + topAlpha: Float = 0.18f, + bottomAlpha: Float = 0.04f, + baseColor: Color +): Modifier = this.border( + width = strokeWidth, + brush = Brush.verticalGradient( + 0.0f to baseColor.copy(alpha = topAlpha), + 1.0f to baseColor.copy(alpha = bottomAlpha) + ), + shape = shape +) + +/** + * The core Yuma glass surface. Always chain [Modifier.yumaClickable] BEFORE this so the + * whole card scales as one unit on press (see SKILL.md §4.3). + */ +@Composable +fun Modifier.yumaGlassCard( + shape: Shape = RoundedCornerShape(YumaTokens.CardRadius), + backgroundColor: Color? = null, + borderColor: Color? = null, + strokeWidth: Dp = YumaTokens.GlassBorderThickness, + position: YumaSegmentPosition = YumaSegmentPosition.Single, + topAlpha: Float? = null, + bottomAlpha: Float? = null +): Modifier { + val glass = yumaGlassColors() + val bg = backgroundColor ?: glass.background + val bc = borderColor ?: glass.border + val alphas = yumaSegmentAlphas(position) + val top = topAlpha ?: alphas.first + val bot = bottomAlpha ?: alphas.second + return this + .clip(shape) + .background(bg, shape) + .glassBorder(shape, strokeWidth, top, bot, bc) +} + +/** + * Tactile press feedback: whole-element spring scale to [PressScale] with a light haptic tick. + * Mirrors YumaPlayer's [yumaClickable] (SKILL.md §4.2). + */ +@Composable +fun Modifier.yumaClickable( + enabled: Boolean = true, + pressedScale: Float = YumaTokens.PressScale, + haptic: Boolean = true, + onClick: () -> Unit +): Modifier { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed && enabled) pressedScale else 1f, + animationSpec = spring(dampingRatio = 0.6f, stiffness = androidx.compose.animation.core.Spring.StiffnessMedium), + label = "yumaPress" + ) + val view = LocalView.current + return this + .scale(scale, scale) + .clickable( + interactionSource = interactionSource, + indication = null, + enabled = enabled, + onClick = { + if (haptic) view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + onClick() + } + ) +} + +/** Convenience glass card composable with optional whole-card click handling. */ +@Composable +fun YumaGlassCard( + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(YumaTokens.CardRadius), + position: YumaSegmentPosition = YumaSegmentPosition.Single, + onClick: (() -> Unit)? = null, + content: @Composable BoxScope.() -> Unit +) { + Box( + modifier = modifier + .then(if (onClick != null) Modifier.yumaClickable(onClick = onClick) else Modifier) + .yumaGlassCard(shape = shape, position = position) + .clip(shape), + content = content + ) +} diff --git a/androidApp/app/src/main/java/com/telebox/app/viewmodel/DashboardViewModel.kt b/androidApp/app/src/main/java/com/telebox/app/viewmodel/DashboardViewModel.kt index 790996f..4b7a6ec 100644 --- a/androidApp/app/src/main/java/com/telebox/app/viewmodel/DashboardViewModel.kt +++ b/androidApp/app/src/main/java/com/telebox/app/viewmodel/DashboardViewModel.kt @@ -24,6 +24,7 @@ import com.telebox.app.data.TransferStatus import com.telebox.app.data.ViewMode import com.telebox.app.util.computeLibraryStats import com.telebox.app.util.formatBytes +import com.telebox.app.util.isImageFile import com.telebox.app.util.isMediaFile import com.telebox.app.util.isPdfFile import com.telebox.app.util.librarySectionFor @@ -84,7 +85,8 @@ data class DashboardUiState( val showTransfers: Boolean = false, val showDownloadsWindow: Boolean = false, val localDownloads: List = emptyList(), - val isClearingCache: Boolean = false + val isClearingCache: Boolean = false, + val thumbnails: Map = emptyMap() ) data class ContextMenuState( @@ -448,6 +450,30 @@ class DashboardViewModel( } } + private val thumbnailJobs = mutableMapOf() + + /** + * Lazily fetches an inline thumbnail for an image file and stores the resulting + * model (base64 data URL) in [DashboardUiState.thumbnails]. This is what makes image + * thumbnails actually render in the file grid / list — previously nothing requested them. + */ + fun requestThumbnail(file: TelegramFile) { + if (file.type == ItemType.FOLDER) return + if (!isImageFile(file.name)) return + val id = file.id + if (_state.value.thumbnails.containsKey(id)) return + if (thumbnailJobs.containsKey(id)) return + thumbnailJobs[id] = viewModelScope.launch { + runCatching { repository.getThumbnail(file.id, file.folderId ?: _state.value.activeFolderId) } + .onSuccess { src -> + if (!src.isNullOrEmpty()) { + _state.update { it.copy(thumbnails = it.thumbnails + (id to src)) } + } + } + thumbnailJobs.remove(id) + } + } + fun navigatePreview(step: Int) { val current = _state.value if (current.previewContextFiles.isEmpty()) return