fix(rtl): keep playback and time controls left-to-right, stop mirroring icons, lay text out by its own direction, add the values-he Hebrew folder and let the launch banner take its picture's height

This commit is contained in:
maxrave-dev 2026-10-14 23:50:05 +07:00
parent 07c86b31a5
commit 537c07e8f8
No known key found for this signature in database
27 changed files with 2906 additions and 1786 deletions

View file

@ -34,8 +34,9 @@ Then edit the downloaded file:
1. `package com.example.test` → `package com.maxrave.simpmusic.ui.icon`
2. `public val <symbol_name>: ImageVector` → `val SimpIcons.<PascalName>: ImageVector`
3. Rename the backing field `_<symbol_name>` → `_<PascalName>`, and `name = "<symbol_name>"` → `"<PascalName>"`
4. If the glyph must flip in RTL, add `autoMirror = true,` inside `ImageVector.Builder(...)`
(currently: `ArrowForwardIos`, `QueueMusic`, `Sort`, `TrendingUp`, `VolumeOff`, `VolumeUp`)
4. Never add `autoMirror = true` to `ImageVector.Builder(...)`: in right-to-left languages only text
flips, icons keep their shape (owner's rule, 2026-10-14). `ArrowForwardIos`, `QueueMusic`, `Sort`,
`TrendingUp`, `VolumeOff` and `VolumeUp` had it and drew mirrored until it was removed.
## Using an icon

View file

@ -312,7 +312,7 @@ filled states render identically.
1. `package com.example.test` → `package com.maxrave.simpmusic.ui.icon`
2. `public val <symbol_name>: ImageVector` → `val SimpIcons.<PascalName>: ImageVector`
3. Rename the backing field `_<symbol_name>` → `_<PascalName>`, and `name = "<symbol_name>"` → `"<PascalName>"`
4. For an icon that must flip in RTL, add `autoMirror = true,` to `ImageVector.Builder`
4. Never add `autoMirror = true`: in right-to-left languages only text flips, icons keep their shape (owner's rule, 2026-10-14). The six icons that had it — `ArrowForwardIos`, `QueueMusic`, `Sort`, `TrendingUp`, `VolumeUp`, `VolumeOff` — drew mirrored in Hebrew/Arabic/Persian until it was removed
**Use it:** `SimpIcons.PlayArrow` — plus a per-icon import, `import com.maxrave.simpmusic.ui.icon.PlayArrow`.
@ -992,7 +992,7 @@ if (getPlatform() == Platform.Android) {
- **Linux slice: libbz2 bundled, the AppImage excludelist left to the host (2026-10-08, issue #2462)**: `scripts/mpv-linux/stage.sh` listed `libbz2.so.1.0` in `SYSTEM_LIBS`, but that soname exists only on Debian/Ubuntu (Fedora/RHEL ship `libbz2.so.1`), so libmpv could not load there and every song timed out. It is now bundled. In the other direction, the 11 AppImage community excludelist libraries this closure pulls in (fontconfig, freetype, harfbuzz, expat, fribidi, X11, X11-xcb, xcb, asound, gpg-error, uuid), plus Xau and Xdmcp which only libxcb needs, are now left to the host. libmpv loads at startup, before the Compose window exists, and glibc reuses an already-loaded soname even under `RTLD_LOCAL`, so a bundled copy of any of them replaces the host's for the whole process: on Fedora 44, once libmpv could load (a user symlinked `libbz2.so.1.0`), Skia drew a black window.
- **Rebuilt and re-pinned on 2026-10-10.** The new tarball has 38 libs instead of 50 (SHA-256 `f583c295…24c4`), and it replaced the `abc` asset. CI only downloads the tarball pinned in `mpvNativesChecksums`, so a change to `stage.sh` reaches users only after that rebuild, upload and re-pin. Docker 29 (containerd image store) resolves a plain `FROM ubuntu:22.04` to the host's arm64 image even after `docker pull --platform linux/amd64`, so on Apple Silicon build from a copy of the context whose `FROM` names the amd64 manifest digest, then check that `e_machine == 62`.
- **Launch banners from the remote config (2026-10-10)**: Home can show image-only promo banners, at most one per launch, defined by a `promos` array in the same `remote-config.json` that already carries the TIDAL credentials. Each entry is `id` (required: names the banner), `image` (language code → portrait banner URL; a missing or blank language falls back to `en`, and the code is the BCP 47 tag's first part, since Android's `Locale.language` still says `in`/`iw` for Indonesian/Hebrew), `link` (a `simpmusic://` deep link, routed through the same `App.kt` router as web and widget links, or an `https://` page opened with `openUrl`; an entry with any other link is skipped, since a tap could not act on it — one check, `isAppLinkAvailable(link, …)` in `utils/AppLinks.kt`), and optional `minVersion` / `requires` (only `local_tracking` is understood; an unknown requirement hides the entry from that build). Entries decode one by one, so one written with a wrong type drops alone — but a JSON syntax error anywhere in the file still fails the whole file, TIDAL included. Model in `core/domain/.../model/promo/Promo.kt`, selection in `utils/PromoSelection.kt` (pure, tested in `jvmTest`), UI in `ui/component/PromoBanner.kt`: the picture in a 9:16 portrait frame — banners are 720×1280; another shape is cropped — at 82% of the width and at most 75% of the height, with the app's holder placeholder while it loads; tap to open the link, and only the Wrapped glass X closes it — the owner asked that a tap outside and Back do nothing.
- **Launch banners from the remote config (2026-10-10)**: Home can show image-only promo banners, at most one per launch, defined by a `promos` array in the same `remote-config.json` that already carries the TIDAL credentials. Each entry is `id` (required: names the banner), `image` (language code → portrait banner URL; a missing or blank language falls back to `en`, and the code is the BCP 47 tag's first part, since Android's `Locale.language` still says `in`/`iw` for Indonesian/Hebrew), `link` (a `simpmusic://` deep link, routed through the same `App.kt` router as web and widget links, or an `https://` page opened with `openUrl`; an entry with any other link is skipped, since a tap could not act on it — one check, `isAppLinkAvailable(link, …)` in `utils/AppLinks.kt`), and optional `minVersion` / `requires` (only `local_tracking` is understood; an unknown requirement hides the entry from that build). Entries decode one by one, so one written with a wrong type drops alone — but a JSON syntax error anywhere in the file still fails the whole file, TIDAL included. Model in `core/domain/.../model/promo/Promo.kt`, selection in `utils/PromoSelection.kt` (pure, tested in `jvmTest`), UI in `ui/component/PromoBanner.kt`: the picture fills a width capped at 320dp in portrait and 360dp in landscape (less only when a 9:16 picture would pass 80% of the window height), and its own shape sets the height — no fixed frame; the owner rejected the old 9:16 box as inconsistent. The app's holder placeholder (square) shows while it loads, so the banner grows taller once the picture arrives; tap to open the link, and only the Wrapped glass X closes it — the owner asked that a tap outside and Back do nothing.
- **Each banner once per app version, one per launch, in the config's order.** `promo_shown` holds the app version on its first line and the ids shown in that version after it, so a new version starts the line over. A launch takes the first eligible banner not shown yet; closing or tapping it counts, stepping aside for anything does not, so a later launch offers it again.
- **The picture is just an `AsyncImage` in the banner's dialog** — Coil loads it like any other image (disk cache first, the network otherwise, through the 24-hour TTL interceptor). There is no prefetch and no cache-only check: an earlier version prefetched pictures and refused any not already on disk, a leftover from when the banner was decided the moment Home opened, and the owner rejected it ("coil giải quyết"). A picture that fails to load closes the banner without counting it, so a later launch tries again; offline with the picture never loaded, that means the dialog opens and closes at once. `CommonRepositoryImpl` stores the raw `promos` block in DataStore after each config fetch (`"[]"` when the block is gone, which ends a campaign), and the decision reads whatever block is saved when its turn comes — often this launch's own fetch.
- **It waits its turn and never shares the screen.** Once per launch, Home waits for its open counter, 1 s for its own launch dialogs (review, share lyrics, blog, Kotlin Footguns) to settle, then for the update check started at launch, however long it takes (`checkForUpdate()` raises `isCheckingUpdate` before its coroutine starts and always lowers it on completion, so the flag can neither be missed nor stick). The decided banner draws only after 400 ms in which nothing has blocked it: another dialog (Home's own, the log-in alert, and everything `App.kt` raises — update dialog, both notification-permission prompts, the sleep timer — folded into `LocalAppDialogOpen`), Now Playing (`LocalNowPlayingOpen`), or Home not being in front — RESUMED on Android (not mid-transition to another tab, not behind a system prompt), merely STARTED on Desktop (window showing): a Desktop window opened from a terminal or an IDE may start without focus, and the click that focuses it would count as the user starting, so a RESUMED gate there would never let the banner through. The 400 ms belongs to the banner (`promoClearFor` holds the banner it cleared), so it always starts at the decision: nothing left over from before the banner arrived can let it through early, and the update dialog, raised a frame after its check ends, gets there first.
@ -1010,7 +1010,14 @@ if (getPlatform() == Platform.Android) {
- **FOSS builds no longer get Sentry bytecode injected (2026-10-11)**: the Sentry Android Gradle plugin runs on androidApp in BOTH builds, and its tracing instrumentation rewrites classes at build time, dependencies included — it inserts `AppStartMetrics.onContentProviderCreate/PostCreate` into every `ContentProvider.onCreate`, `androidx.startup.InitializationProvider` among them. FOSS ships `crashlytics-empty`, so those calls name classes its APK does not contain: after the 2026-10-10 bump (AGP 9.2.1 → 9.4.1, plugin 6.22.0 → 6.23.0) the FOSS build crashed at launch with `NoClassDefFoundError: Failed resolution of: Lio/sentry/android/core/performance/AppStartMetrics;` from `InitializationProvider.onCreate`. The FOSS branch of the `sentry {}` block now sets `tracingInstrumentation.enabled.set(false)`, which keeps the plugin from registering its class transform at all (`if (tracingInstrumentationEnabled)` in its `AndroidComponentsConfig.kt`).
- **`ignoredFlavors.set(setOf("foss"))` in that block never excluded anything**: the project has no product flavors — Full/FOSS is the `isFullBuild` property — so the plugin has always run on the FOSS release variant.
- **An un-obfuscated Sentry class name in a release stack trace means the class is not in the APK.** R8 renames every class it has (the Full build turns `AppStartMetrics` into `io.sentry.android.core.performance.g`); a reference to a missing class keeps its original name.
- **An un-obfuscated Sentry class name in a release stack trace means the class is not in the APK.**
- **Right-to-left languages (2026-10-14, Hebrew/Arabic/Persian)**: an audit on a Hebrew device found three separate causes. The owner's rule behind all of them: in RTL only text flips, icons keep their shape and anything that shows which way time runs stays left-to-right.
- **Hebrew moved folders.** Android 15+ with targetSdk ≥ 35 and the Desktop JDK report Hebrew/Indonesian as `he`/`id` (AOSP `BaseLocale.USE_NEW_ISO_LOCALE_CODES`); Android ≤ 14 still reports `iw`/`in`; Compose Resources match the folder name exactly. Crowdin exports `he`/`id`, so `values-he` and `values-id` are the live folders and `values-iw`/`values-in` serve old phones only — nothing updates them unless they are copied.
- **Playback and time controls stay left-to-right** through `LeftToRight { }` (`extension/UIExt.kt`, a `LocalLayoutDirection.Ltr` provider): every player style's seek bar, times and transport (`AppleMusicPlaybackControls`, `SpotifyPlaybackControls`, `ExpressivePlaybackControls`, `PlayerControlLayout`), volume rows, the action rows under them, the three artwork pagers, the ±5 s video overlays, fullscreen lyrics/video, the mini player's progress, the Desktop capsule and mini-player window, the Analytics period navigator and the Wrapped reel. Before this, ⏩ sat on the left, every times row (`TextAlign.Left/Right` on weighted halves) collapsed onto its centre label, and M3E's `WavySeekBar` seeked backwards: its touch math is `x / width` while Material's indicator draws mirrored. A control of this kind added later needs the same wrapper. Text placed inside one (the Analytics date, Wrapped's cards) gets the reading direction provided back, or a right-to-left string comes out reordered.
- **Compose lays every paragraph out in the layout's direction by default**: `TextStyle.resolveTextDirection` maps `TextDirection.Unspecified` to `Rtl`, not to the content. Latin titles and URLs therefore lost their punctuation order (".release", "/https://…") and titles mixing Latin with YouTube's localized "(עם …)" came out scrambled and clipped. `typo()` now sets `TextDirection.Content` plus `TextAlign.Right` in RTL layouts only — Content alone would also move Latin text to the left edge, since Compose aligns `Start` by the paragraph, unlike a TextView's gravity. Lyrics are unaffected: every lyric line sets its own direction and alignment.
- Smaller fixes: `MoodCategoryCard` drew its badge at a fixed right corner, on top of the title in RTL; the equalizer's curve is drawn left-to-right, so its dB scale and frequency labels are kept left-to-right with it. The Glance `TurntableWidget` cannot pin a direction, and the launcher lays it out in the SYSTEM's direction rather than the app's language, so it reverses its own order when `Resources.getSystem()` is RTL.
- Compose Desktop goes right-to-left too: `GlobalLayoutDirection` comes from `Locale.getDefault()` through AWT's `ComponentOrientation`.
- Left as is: marquee titles scroll the RTL way for Latin text (`basicMarquee` follows the layout, not the text) — the owner declined changing it. Still open: the back button sits on the right while its arrow keeps pointing left. R8 renames every class it has (the Full build turns `AppStartMetrics` into `io.sentry.android.core.performance.g`); a reference to a missing class keeps its original name.
## 🔄 CLAUDE.md Auto-Update Rule (MANDATORY)
@ -1036,6 +1043,6 @@ After completing any of the following types of changes, the AI agent **MUST** up
*This document helps AI Agents quickly understand the SimpMusic project. Update regularly when there are major changes to architecture or structure.*
**Last updated**: 2026-10-11
**Last updated**: 2026-10-14
**Project version**: Check latest release on GitHub
**Maintained by**: maxrave-dev and contributors

View file

@ -2,7 +2,10 @@ package com.maxrave.simpmusic.ui.widget
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
import android.graphics.Bitmap
import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
@ -183,6 +186,38 @@ class TurntableWidget :
// Glance a background lands on the View's background, and a View's background
// is painted underneath its padding — so padding on the same element only
// stretches the pill instead of moving it.
// The launcher lays this row out in the SYSTEM's direction, not the app's language,
// and Glance cannot pin it left-to-right, so a right-to-left system gets the order
// reversed here instead: ⏮ stays left of ⏭, the way the player shows them.
val systemRtl = Resources.getSystem().configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL
val previous: @Composable () -> Unit = {
Image(
provider = ImageProvider(R.drawable.rounded_fast_rewind_24),
contentDescription = "Previous",
colorFilter = ColorFilter.tint(ColorProvider(Color.White)),
modifier =
GlanceModifier
.size(34.dp)
.padding(5.dp)
.clickable(rippleOverride = R.drawable.no_ripple) {
sharedViewModel.onUIEvent(UIEvent.Previous)
},
)
}
val next: @Composable () -> Unit = {
Image(
provider = ImageProvider(R.drawable.rounded_fast_forward_24),
contentDescription = "Next",
colorFilter = ColorFilter.tint(ColorProvider(Color.White)),
modifier =
GlanceModifier
.size(34.dp)
.padding(5.dp)
.clickable(rippleOverride = R.drawable.no_ripple) {
sharedViewModel.onUIEvent(UIEvent.Next)
},
)
}
Box(modifier = GlanceModifier.padding(bottom = 16.dp)) {
Row(
modifier =
@ -192,18 +227,7 @@ class TurntableWidget :
horizontalAlignment = Alignment.CenterHorizontally,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
provider = ImageProvider(R.drawable.rounded_fast_rewind_24),
contentDescription = "Previous",
colorFilter = ColorFilter.tint(ColorProvider(Color.White)),
modifier =
GlanceModifier
.size(34.dp)
.padding(5.dp)
.clickable(rippleOverride = R.drawable.no_ripple) {
sharedViewModel.onUIEvent(UIEvent.Previous)
},
)
if (systemRtl) next() else previous()
Spacer(GlanceModifier.width(8.dp))
Box(
modifier =
@ -230,18 +254,7 @@ class TurntableWidget :
)
}
Spacer(GlanceModifier.width(8.dp))
Image(
provider = ImageProvider(R.drawable.rounded_fast_forward_24),
contentDescription = "Next",
colorFilter = ColorFilter.tint(ColorProvider(Color.White)),
modifier =
GlanceModifier
.size(34.dp)
.padding(5.dp)
.clickable(rippleOverride = R.drawable.no_ripple) {
sharedViewModel.onUIEvent(UIEvent.Next)
},
)
if (systemRtl) previous() else next()
}
}
}

View file

@ -0,0 +1,975 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="Song_and_artist_name">שיר • %1$s</string>
<string name="playlist_and_author">רשימת השמעה • %1$s</string>
<string name="album_and_artist_name">אלבום • %1$s</string>
<string name="subscribers">%1$s מנויים</string>
<string name="year_and_category">%1$s • %2$s</string>
<string name="album_length">%1$s שירים %2$s</string>
<string name="view_count">%1$s צפיות</string>
<string name="clear_search_history">נקה היסטוריית חיפוש</string>
<string name="liked">אהבת</string>
<string name="liked_songs">שירים שאהבת</string>
<string name="liked_songs_by">על ידי %1$s</string>
<plurals name="liked_songs_count">
<item quantity="one">שיר %1$d</item>
<item quantity="two">%1$d שירים</item>
<item quantity="other">%1$d שירים</item>
</plurals>
<plurals name="library_artist_count">
<item quantity="one">זמר %1$d</item>
<item quantity="two">%1$d זמרים</item>
<item quantity="other">%1$d זמרים</item>
</plurals>
<string name="library_favorite_empty_hint">לחץ על כפתור הלב בשיר כלשהו כדי לשמור אותו כאן.</string>
<string name="like">לאהוב</string>
<string name="hello_blank_fragment">שלום קטע ריק</string>
<string name="download">הורדה</string>
<string name="downloaded">הורד</string>
<string name="downloading">מוריד</string>
<string name="preparing">מכין</string>
<string name="hide">הסתר</string>
<string name="show">הצג</string>
<string name="na_na">NA:NA</string>
<string name="live_badge">שידור חי</string>
<string name="cache_size">%1$s מ"ב</string>
<string name="favorite">מועדף</string>
<string name="followed">עוקב</string>
<string name="most_played">הכי מושמעים</string>
<string name="Downloaded">הורד</string>
<string name="favorite_playlists">פלייליסטים מועדפים</string>
<string name="no_favorite_playlists">אין פלייליסטים מועדפים</string>
<string name="downloaded_playlists">פלייליסטים שהורדו</string>
<string name="no_playlists_downloaded">אין פלייליסטים שהורדו</string>
<string name="your_playlists">הפלייליסטים המקומיים שלך</string>
<string name="no_playlists_added">לא נוספו פלייליסטים</string>
<string name="recently_added">נוספו לאחרונה</string>
<string name="search_for_songs_artists_albums_playlists_and_more">חפש שירים, אמנים, אלבומים, פלייליסטים, ועוד</string>
<string name="everything_you_need">כל מה שאתה צריך</string>
<string name="restore_failed">שחזור נכשל</string>
<string name="restore_success">שוחזר בהצלחה</string>
<string name="search">חיפוש</string>
<string name="songs">שירים</string>
<string name="albums">אלבומים</string>
<string name="playlists">פלייליסטים</string>
<string name="artists">אמנים</string>
<string name="library">ספרייה</string>
<string name="home">מסך הבית</string>
<string name="popular">פופולרי</string>
<string name="error">שגיאה</string>
<string name="follow">עקוב</string>
<string name="retry">נסה שוב</string>
<string name="removed_download">הורדה הוסרה</string>
<string name="share_url">שתף קישור</string>
<string name="available_online">זמין באינטרנט</string>
<string name="unavailable">לא זמין</string>
<string name="you">אתה</string>
<string name="good_morning">בוקר טוב</string>
<string name="good_afternoon">אחר צהריים טובים</string>
<string name="good_evening">ערב טוב</string>
<string name="good_night">לילה טוב</string>
<string name="quick_picks">בחירות מהירות</string>
<string name="this_song_is_not_available">שיר זה אינו זמין</string>
<string name="in_charts">בתרשימים</string>
<string name="playlist_name_cannot_be_empty">שם הפלייליסט לא יכול להיות ריק</string>
<string name="this_app_needs_to_access_your_notification">האפליקציה הזו צריכה לגשת להתראה שלך</string>
<string name="shared">משותף</string>
<string name="rich_synced">מילה-מילה</string>
<string name="line_synced">קו מסונכרן</string>
<string name="unsynced">לא מסונכרן</string>
<string name="playlist_is_empty">הפלייליסט ריק</string>
<string name="option">אפשרות</string>
<string name="no_description">אין תיאור</string>
<string name="in_search">בחיפוש</string>
<string name="clear_player_cache">נקה את מטמון הנגן</string>
<string name="cancel">ביטול</string>
<string name="clear">נקה</string>
<string name="content_country">מדינת תוכן</string>
<string name="change">שינוי</string>
<string name="quality">איכות</string>
<string name="prefer_320kbps_stream">העדף הזרמת 320kbps</string>
<string name="prefer_320kbps_stream_description">חפש אוטומטית זרמי אודיו
320kbps כאשר זמין באינטרנט. נופל חזרה לאיכות ברירת המחדל אם לא נמצא.</string>
<string name="download_quality">איכות הורדה</string>
<string name="video_download_quality">איכות הורדת וידאו</string>
<string name="clear_downloaded_cache">נקה את המטמון שהורד</string>
<string name="clear_thumbnail_cache">נקה מטמון תמונות ממוזערות</string>
<string name="add">הוסף</string>
<string name="playlist_name">שם הפלייליסט</string>
<string name="create">צור</string>
<string name="edit">ערוך</string>
<string name="edit_title">ערוך כותרת</string>
<string name="edit_thumbnail">ערוך תמונה ממוזערת</string>
<string name="delete_playlist">מחק פלייליסט</string>
<string name="add_to_a_playlist">הוסף לפלייליסט</string>
<string name="share">שתף</string>
<string name="move_up">הזז למעלה</string>
<string name="move_down">הזז למטה</string>
<string name="delete_from_queue">מחק מהתור</string>
<string name="more">הצג עוד</string>
<string name="play_all">נגן הכל</string>
<string name="singles">סינגלים</string>
<string name="related_artists">אמנים קשורים</string>
<string name="about">אודות</string>
<string name="let_s_start_with_a_radio">בואו נתחיל עם רדיו</string>
<string name="let_s_pick_a_playlist_for_you">בוא נבחר עבורך רשימת השמעה</string>
<string name="moods_amp_moment"><![CDATA[מצבי רוח ורגעים]]></string>
<string name="genre">ז'אנר</string>
<string name="what_is_best_choice_today">מהי הבחירה הטובה ביותר היום</string>
<string name="chart">תרשים</string>
<string name="global">גלובלי</string>
<string name="top_tracks">שירים מובילים</string>
<string name="top_artists">אמנים מובילים</string>
<string name="your_playlist">הפלייליסט שלך</string>
<string name="lyrics">מילים</string>
<string name="all">הכל</string>
<string name="videos">סרטונים</string>
<string name="content">תוכן</string>
<string name="storage">אחסון</string>
<string name="player_cache">מטמון נגן</string>
<string name="downloaded_cache">מטמון שהורד</string>
<string name="thumbnail_cache">מטמון תמונות ממוזערות</string>
<string name="backup">גיבוי</string>
<string name="backup_your_data">גבה את הנתונים שלך</string>
<string name="save_all_your_playlist_data">שמור את כל נתוני הפלייליסטים שלך</string>
<string name="restore_your_data">שחזר את הנתונים שלך</string>
<string name="restore_your_saved_data">שחזר את הנתונים השמורים שלך</string>
<string name="about_us">אודות</string>
<string name="version">גרסה</string>
<string name="github">Github</string>
<string name="maxrave_dev">maxrave-dev</string>
<string name="follow_me_on_x">עקוב אחריי ב-X</string>
<string name="buy_me_a_coffee">קנה לי קפה</string>
<string name="developer_blog">בלוג המפתח</string>
<string name="developer_blog_tagline">קרא הערות מפתח, מדריכים &amp; ומאחורי-הקלעים</string>
<string name="blog_promo_title">משהו חדש מהמפתח</string>
<string name="blog_promo_message">השקתי את הבלוג של SimpMusic ב-simpmusic.org - אני משתף את הסיפור מאחורי SimpMusic, צלילות מעמיקות בטכנולוגיית מוזיקה והערות הנדסיות. קראו אותו אם אתם סקרנים ✨</string>
<string name="visit_blog">בקרו בבלוג</string>
<string name="paypal">PayPal</string>
<string name="title">כותרת</string>
<string name="custom_order">הזמנה מותאמת אישית</string>
<string name="album">אלבום</string>
<string name="youtube_url">קישור ל-YouTube</string>
<string name="download_url">הורד קישור</string>
<string name="now_playing">מנגן כעת</string>
<string name="queue">תור</string>
<string name="recently">לאחרונה</string>
<string name="settings">הגדרות</string>
<string name="now_playing_upper">מתנגן כעת</string>
<string name="language">שפה</string>
<string name="what_do_you_want_to_listen_to">מה אתה רוצה לשמוע?</string>
<string name="songs_inArtist">שירים</string>
<string name="albums_inArtist">אלבומים</string>
<string name="singles_inArtist">סינגלים</string>
<string name="fans_might_also_like_inArtist">מעריצים עשויים לאהוב גם</string>
<string name="crash_log">SimpMusic התרסק. סליחה על הבעיה הזו.\nשלח יומן קריסה למפתח</string>
<string name="crash">קריסה</string>
<string name="send">שלח</string>
<string name="mail_subject">דווח על קריסה</string>
<string name="delete_song_from_playlist">מחק את השיר הזה מהפלייליסט</string>
<string name="log_in">התחבר</string>
<string name="log_in_to_YouTube">התחבר ל-YouTube</string>
<string name="log_in_to_get_personally_data">התחבר כדי לקבל נתונים אישיים</string>
<string name="login_success">ההתחברות בוצעה בהצלחה</string>
<string name="login_failed">ההתחברות נכשלה</string>
<string name="log_out">התנתק מ-Youtube</string>
<string name="logged_in">מחובר</string>
<string name="logged_out">לא מחובר</string>
<string name="playlist">פלייליסט</string>
<string name="restart_app">הפעל מחדש את האפליקציה כדי להחיל את השינוי</string>
<string name="balance_media_loudness">איזון עוצמת מדיה</string>
<string name="normalize_volume">נרמל את עוצמת הקול</string>
<string name="audio">אודיו</string>
<string name="no_equalizer">למכשיר שלך אין אקולייזר</string>
<string name="open_system_equalizer">פתח אקולייזר מערכת</string>
<string name="use_your_system_equalizer">השתמש באקולייזר המערכת</string>
<string name="streaming_data_provider_piped">ספק נתוני סטרימינג (Piped)</string>
<string name="itag">Itag</string>
<string name="extract_source">מקור חילוץ</string>
<string name="mime_type">סוג Mime</string>
<string name="bitrate">קצב סיביות</string>
<string name="warning">אזהרה</string>
<string name="change_language_warning">שינוי השפה יסגור את האפליקציה שלך. האם אתה בטוח?</string>
<string name="this_link_is_not_supported">קישור זה אינו נתמך</string>
<string name="added_to_playlist">נוסף לפלייליסט</string>
<string name="description">תיאור</string>
<string name="playback">ניגון</string>
<string name="save_playback_state">שמור מצב ניגון</string>
<string name="save_shuffle_and_repeat_mode">שמור מצב ערבוב וחזרה</string>
<string name="skip_silent">דלג על קטעי שקט</string>
<string name="skip_no_music_part">לא לדלג על אף קטע מוזיקה</string>
<string name="time_out_check_internet_connection_or_change_piped_instance_in_settings">פסק זמן, בדוק את חיבור האינטרנט (%1$s)</string>
<string name="save_last_played">שמור הושמע לאחרונה</string>
<string name="save_last_played_track_and_queue">שמור את השיר האחרון שהושמע ואת התור</string>
<string name="update_available">עדכון חדש זמין</string>
<string name="update_message">גרסא %1$s זמינה\nהוצאה: %2$s\n</string>
<string name="check_for_update">בדוק עדכונים</string>
<string name="last_checked_at">נבדק לאחרונה ב %1$s</string>
<string name="checking">בודק</string>
<string name="sponsorblock_skip_segment">SponsorBlock: דלג על %1$s</string>
<string name="sponsor">פרסומות</string>
<string name="self_promotion">קידום עצמי</string>
<string name="interaction">אינטראקציה</string>
<string name="intro">הקדמה</string>
<string name="outro">סיום</string>
<string name="preview">תצוגה מקדימה</string>
<string name="music_off_topic">מוזיקה מחוץ לנושא</string>
<string name="poi_highlight">הדגשה של POI</string>
<string name="filler">ממלא</string>
<string name="sponsorBlock">SponsorBlock</string>
<string name="enable_sponsor_block">הפעל SponsorBlock</string>
<string name="categories_sponsor_block">בחר התנהגות של דילוג על קטע</string>
<string name="what_segments_will_be_skipped">אילו קטעים ידולגו</string>
<string name="save">שמור</string>
<string name="crop_cover">חתוך תמונת קאבר</string>
<string name="saved">נשמר</string>
<string name="sponsor_block_intro">SponsorBlock היא מערכת מיקור המונים לדילוג על חלקים מעצבנים בסרטוני יוטיוב.\nמידע נוסף: https://sponsor.ajay.app/\n ב-SimpMusic, חסימת חסות זמין רק כאשר המכשיר מחובר לאינטרנט</string>
<string name="skip_sponsor_part_of_video">דלג על חלק הפרסומות בסרטון</string>
<string name="customactivityoncrash_error_activity_error_occurred_explanation">שגיאה לא צפויה התרחשה. מצטערים על אי הנוחות.</string>
<string name="customactivityoncrash_error_activity_restart_app">הפעל מחדש את האפליקציה</string>
<string name="help_build_lyrics_database">עזור ל-SimpMusic lyrics לבנות את מאגר המילים</string>
<string name="help_build_lyrics_database_description">שלחו את המילים ששמרתם למאגר המילים של SimpMusic lyrics</string>
<string name="customactivityoncrash_error_activity_close_app">סגור את האפליקציה</string>
<string name="customactivityoncrash_error_activity_error_details">פרטי שגיאה</string>
<string name="customactivityoncrash_error_activity_error_details_title">פרטי שגיאה</string>
<string name="customactivityoncrash_error_activity_error_details_close">סגור</string>
<string name="customactivityoncrash_error_activity_error_details_copy">העתקה ללוח</string>
<string name="customactivityoncrash_error_activity_error_details_copied">הועתק ללוח</string>
<string name="customactivityoncrash_error_activity_error_details_clipboard_label">מידע על שגיאה</string>
<string name="no_update">הגרסה שלך היא העדכנית ביותר</string>
<string name="sleep_timer">טיימר שינה - %1$s דקות</string>
<string name="sleep_timer_end_of_song">טיימר לשינה - עד סוף השיר</string>
<string name="sleep_timer_off">טיימר שינה</string>
<string name="sleep_minutes">זמן ספירה לאחור (דקות)</string>
<string name="set">הגדר</string>
<string name="sleep_timer_off_done">עצירת טיימר השינה בוצעה</string>
<string name="sleep_timer_set_error">נא להגדיר את פורמט הזמן הנכון</string>
<string name="sleep_timer_warning">למחוק טיימר שינה?</string>
<string name="yes">כן</string>
<string name="welcome_back">ברוך שובך,</string>
<string name="upload_your_listening_history_to_youtube_music_server_it_will_make_yt_music_recommendation_system_better_working_only_if_logged_in">העלה את היסטוריית ההאזנה שלך לשרת YouTube Music, ושפר את מערכת ההמלצות. עובד רק אם אתה מחובר</string>
<string name="send_back_listening_data_to_google">שלח נתוני האזנה חזרה ל-Google</string>
<string name="videos_inArtist">סרטונים</string>
<string name="your_youtube_playlists">הפלייליסטים שלך ב-YouTube</string>
<string name="no_YouTube_playlists">אין פלייליסטים ב-YouTube</string>
<string name="track">שיר(ים)</string>
<string name="save_to_local_playlist">שמור הפלייליסט המקומי</string>
<string name="added_local_playlist">נוסף פלייליסט מקומי</string>
<string name="sync">סנכרן עם YouTube Music</string>
<string name="synced">מסונכרן עם YouTube Music</string>
<string name="song">שיר</string>
<string name="radio">רדיו</string>
<string name="auto_created_by_youtube_music">נוצר אוטומטית על ידי YouTube Music</string>
<string name="start_radio">הפעל רדיו</string>
<string name="saved_to_local_playlist">נשמר בפלייליסט המקומי</string>
<string name="if_you_want_to_unsync_this_playlist_please_go_to_local_playlist">אם ברצונך לבטל את הסנכרון של פלייליסט זה, אנא עבור לפלייליסט מקומי</string>
<string name="log_in_to_get_YouTube_playlist">התחבר כדי לקבל פלייליסט YouTube</string>
<string name="sync_playlist_warning">SimpMusic תיצור פלייליסט YouTube Music באופן אוטומטי. האם אתה בטוח?</string>
<string name="syncing">מסנכרן</string>
<string name="unsync_playlist_warning">"לבטל את הסנכרון של הפלייליסט הזה? הפלייליסט הזה לא יוסר מ-YouTube Music"</string>
<string name="unsyncing">מבטל סנכרון</string>
<string name="added_to_youtube_playlist">נוסף לפלייליסט של YouTube</string>
<string name="removed_from_YouTube_playlist">הוסר מהפלייליסט של YouTube</string>
<string name="update_playlist">עדכן פלייליסט מ-YouTube Music</string>
<string name="backup_create_success">הגיבוי נוצר בהצלחה</string>
<string name="backup_create_failed">יצירת הגיבוי נכשלה</string>
<string name="main_lyrics_provider">ספק מילים ראשי</string>
<string name="use_ai_translation">השתמש בבינה מלאכותית לתרגום מילים</string>
<string name="use_ai_translation_description">הפעל תרגום מילים לשירים בעזרת בינה מלאכותית</string>
<string name="translation_language">שפת התרגום</string>
<string name="follow_app_language">עקוב אחר שפת האפליקציה</string>
<string name="email">דוא"ל</string>
<string name="password">סיסמא</string>
<string name="please_enter_your_email_and_password">נא להזין דוא״ל וסיסמא</string>
<string name="translation_language_message">הקלד קוד שפה (למשל: en) (2 אותיות). אם אתה רוצה להגדיר את אותה השפה כמו של SimpMusic, אנא הפוך את תיבת העריכה לריקה.</string>
<string name="invalid_language_code">קוד שפה לא חוקי</string>
<string name="lyrics_provider_youtube">מילים מסופקות על ידי YouTube</string>
<string name="lyrics_provider_betterlyrics">מילים מסופקות על ידי BetterLyrics</string>
<string name="limit_player_cache">הגבל את מטמון הנגן</string>
<string name="unlimited">ללא הגבלה</string>
<string name="third_party_libraries">ספריות צד שלישי</string>
<string name="description_and_licenses">תיאור ורישיונות</string>
<string name="author">מחבר</string>
<string name="other_app">יישום אחר</string>
<string name="database">מסד הנתונים של האפליקציה</string>
<string name="free_space">שטח אחסון פנוי</string>
<string name="suggest">הצעה</string>
<string name="reload">טען מחדש</string>
<string name="sort_by">סדר לפי</string>
<string name="sort">סדר</string>
<string name="older_first">הישן ביותר תחילה</string>
<string name="newer_first">החדש ביותר תחילה</string>
<string name="added_date">תאריך הוספה</string>
<string name="credit_app">אפליקציית מוזיקה פשוטה המשתמשת ב-YouTube Music כבסיס. \n\nSimpMusic הוא פרויקט קוד פתוח להזרמת מוזיקה מ-YouTube ו-YouTube Music ללא פרסומות ומעקבים. SimpMusic מספקת תכונות רבות:\n\n - הזרמת מוזיקה \n - מילים מסונכרנות\n - התאמה אישית של נתונים\n - ועוד… \n\n
SimpMusic תמיד בחינם וללא פרסומות \n\nנבנה ב-❤️ מאת Tuan Minh Nguyen Duc (maxrave-dev)</string>
<string name="issue_tracker">מעקב אחר בעיות</string>
<string name="featured_playlists">פלייליסטים נבחרים</string>
<string name="featured_inArtist">נבחר ב</string>
<string name="audio_quality">איכות אודיו</string>
<string name="play_video_for_video_track_instead_of_audio_only">הפעל וידאו עבור רצועת וידאו במקום אודיו בלבד</string>
<string name="such_as_music_video_lyrics_video_podcasts_and_more">כגון קליפ מוזיקה, וידאו מילים, פודקאסטים, ועוד</string>
<string name="radio_audio_only">נגן גרסת אודיו במקום קליפ מוזיקה ב"מצב רדיו"</string>
<string name="radio_audio_only_description">נגן את גרסת האודיו במקום הקליפים כשיש כאלו, ודלג על רמיקסים ומאש-אפים שקיימים רק כקליפי מוזיקה. פלייליסטים ואלבומים לא מושפעים מהגדרה זו</string>
<string name="video_quality">איכות וידאו</string>
<plurals name="n_song">
<item quantity="one">%d שיר</item>
<item quantity="two">%d שירים</item>
<item quantity="other">%d שירים</item>
</plurals>
<string name="dummy_button">כפתור דמה</string>
<string name="dummy_content">תוכן\nדמה</string>
<string name="play_video_instead_of_audio_warning">פעולה זו תמחק את מטמון הנגן. להמשיך?</string>
<string name="quality_video">איכות וידאו</string>
<string name="add_to_queue">הוסף לתור</string>
<string name="add_to_queue_disabled_radio">הוסף לתור (מושבת כאשר רדיו או מיקס מתנגן)</string>
<string name="add_to_queue_disabled_endless">הוסף לתור (מושבת כאשר 'תור ללא הפסקה' מופעל)</string>
<string name="added_to_queue">נוסף לתור</string>
<string name="new_release">שחרור חדש</string>
<string name="music_video">קליפ מוזיקה</string>
<string name="your_music_is_always_up_to_date">המוזיקה שלך תמיד מעודכנת</string>
<string name="youtube_account">חשבון YouTube</string>
<string name="manage_your_youtube_accounts">נהל את חשבונות ה-YouTube שלך</string>
<string name="sync_follow_to_youtube">סנכרן מעקב עם YouTube</string>
<string name="equalizer">אקווילייזר by</string>
<string name="equalizer_description">עצב את הצליל בעזרת עקומה בת 10 מדרגות</string>
<string name="equalizer_type">סוג אקווילייזר</string>
<string name="equalizer_type_built_in">מובנה</string>
<string name="equalizer_type_system">מערכת</string>
<string name="equalizer_preamp">מגבר-מקדים</string>
<string name="equalizer_presets">תבניות</string>
<string name="equalizer_preset_custom">מותאם אישית</string>
<string name="equalizer_reset">איפוס</string>
<string name="equalizer_autoeq_choose">בחר אוזניות</string>
<string name="equalizer_autoeq_search">דגם אוזניות</string>
<string name="equalizer_autoeq_downloading">מוריד את רשימת הפרופילים של AutoEq…</string>
<string name="equalizer_autoeq_unavailable">לא ניתן היה להשיג מידע מ-AutoEq. בדקו את החיבור ונסו מחדש.</string>
<string name="equalizer_autoeq_empty">אין אוזניות התואמות לשם הזה</string>
<string name="equalizer_autoeq_failed">לא ניתן היה לטעון את הפרופיל הזה - דרוש חיבור אינטרנט לפעם הראשונה</string>
<string name="audio_delay">עיכוב אודיו</string>
<string name="audio_delay_description">הדהד את האודיו עם עיכוב אודיו חוזר</string>
<string name="audio_reverb">הדהוד</string>
<string name="audio_reverb_description">מקם את המוזיקה בחדר, באולם או בצלחת אולפן</string>
<string name="effect_time">זמן</string>
<string name="effect_feedback">משוב</string>
<string name="effect_mix">מיקס</string>
<string name="delay_preset_slapback">סלאפבאק</string>
<string name="delay_preset_quarter">רבע</string>
<string name="delay_preset_dub">דאב</string>
<string name="reverb_preset_room">חדר</string>
<string name="reverb_preset_hall">אולם</string>
<string name="reverb_preset_plate">צלחת אולפן</string>
<string name="reverb_preset_cathedral">קתדרלה</string>
<string name="reverb_help_title">תבניות הדהוד</string>
<string name="reverb_help_intro">כל תבנית היא חדר סינתטי שנוצר משלושה מספרים ומותאמים בעוצמת הקול, כך שהחלפת חדרים משנה את החלל, לא את עוצמת הקול.</string>
<string name="reverb_help_rt60">זנב (RT60): %1$s</string>
<string name="reverb_help_predelay">עיכוב מקדים: %1$s</string>
<string name="reverb_help_damping">ריסון: %1$s</string>
<string name="reverb_help_room">קטן וצפוף. מוסיף תחושת מרחב מבלי למרוח את הצליל.</string>
<string name="reverb_help_hall">אולם קונצרטים. ארוך אך צלול, צליל קלאסי של האטה והדהוד.</string>
<string name="reverb_help_plate">צלחת אולפן. אין פער לפני הריוורב, צליל בהיר וצפוף, משתלב היטב עם השירה.</string>
<string name="reverb_help_cathedral">קתדרלה. ארוך מאוד וחשוך, הצליל מתמוסס בחלל.</string>
<string name="reverb_help_rt60_meaning">RT60 הוא הזמן שלוקח לזנב לדעוך ב-60 dB: כמה גדול החדר מרגיש.</string>
<string name="reverb_help_predelay_meaning">עיכוב מקדים הוא הדממה לפני שההדהוד מתחיל. פער ארוך יותר שומר על הצליל היבש צלול ודוחף את הקירות רחוק יותר.</string>
<string name="reverb_help_damping_meaning">שיכוך הוא המקום שבו הזנב מתחיל לאבד צלילים גבוהים. הוא מתכהה עוד יותר כשהוא דועך, כפי שאוויר ומשטחים רכים סופגים צלילים גבוהים.</string>
<string name="reverb_help_mix">מיקס הוא השיתוף הרטוב: המחוון קובע כמה מהחדר אתה שומע כנגד האות היבש, באופן זהה באנדרואיד ובמחשב שולחני.</string>
<string name="sync_follow_to_youtube_description">מעקב אחר אמן ב-SimpMusic גם נרשם לערוץ שלו בחשבון ה-YouTube שלך</string>
<string name="sync_follow_failed">לא ניתן היה לעדכן את מנוי ה-YouTube שלך</string>
<string name="subscribed_on_youtube">נרשמת כמנוי ב-YouTube</string>
<string name="unsubscribed_on_youtube">בוטלה ההרשמה כמנוי ב-YouTube</string>
<string name="no_account">אין חשבון</string>
<string name="add_an_account">הוסף חשבון</string>
<string name="signed_in">מחובר</string>
<string name="guest">אורח</string>
<string name="log_out_warning">להתנתק מכל החשבונות?</string>
<string name="play_next">נגן את השיר הבא</string>
<string name="codec">מקדד</string>
<string name="plays">כמות ניגונים/צפיות</string>
<string name="like_and_dislike">%1$d לייק(ים), %2$d דיסלייק(ים)</string>
<string name="published_at">פורסם ב-%1$s</string>
<string name="less">פחות</string>
<string name="spotify">Spotify</string>
<string name="log_in_to_spotify">התחבר ל-Spotify</string>
<string name="intro_login_to_spotify">התחבר ל-Spotify מילים, קנבס ועוד</string>
<string name="enable_spotify_lyrics">הפעל מילים מ-Spotify</string>
<string name="spotify_lyrícs_info">עוד מילים ללא תרגום</string>
<string name="enable_canvas">הפעל קנבסים</string>
<string name="canvas_info">הצג קנבסים מ-Spotify ב-SimpMusic</string>
<string name="enable_animated_artwork">גרפיקה מונפשת</string>
<string name="animated_artwork_info">הפעל את קאבר האלבום המונפש במקום קנבסים. אין צורך בחשבון. אלבומים ללא חשבון ישמרו על הקאבר הדומם.</string>
<string name="spotify_lyrics_provider">מילים מסופקות על ידי Spotify</string>
<string name="offline_mode">מצב לא מקוון</string>
<string name="added_to_youtube_liked">נוסף לרשימת התכנים שאהבת ב-YouTube</string>
<string name="removed_from_youtube_liked">הוסר מרשימת התכנים שאהבת ב-YouTube</string>
<string name="home_limit">אחזר את מגבלת נתוני הבית</string>
<string name="no_album">אין אלבום</string>
<string name="app_widget_description">וידג'ט נגן</string>
<string name="guide_miniplayer_content">החלק כדי להציג את לחצן הדילוג והסגירה</string>
<string name="guide_suggest_content">הצג שירים מוצעים מאת בינה מלאכותית בהתבסס על רשימת ההשמעה שלך</string>
<string name="guide_liked_title">הוסף לרשימת התכנים שאהבת ב-YouTube</string>
<string name="log_out_from_spotify">התנתק מ-Spotify</string>
<string name="delete">מחק</string>
<string name="top_videos">סרטונים מובילים</string>
<string name="trending">להיטים</string>
<string name="new_singles">סינגלים חדשים</string>
<string name="new_albums">אלבומים חדשים</string>
<string name="notification">התראה</string>
<string name="month_s_ago">לפני %1$d חודש(ים)</string>
<string name="day_s_ago">לפני %1$d ימים</string>
<string name="hour_s_ago">לפני %1$d שעות</string>
<string name="no_notification">אין התראות</string>
<string name="created_at">נוצר ב: %1$s</string>
<string name="unknown">לא ידוע</string>
<string name="repeat_one">חזור על הנוכחי</string>
<string name="repeat_all">חזור על הכל</string>
<string name="repeat_off">חזרה כבויה</string>
<string name="youtube_transcript">תמלול YouTube</string>
<string name="user_interface">ממשק</string>
<string name="theme">ערכת נושא</string>
<string name="theme_mode_system">בחירת המחדל של המערכת</string>
<string name="theme_mode_dark">ככה</string>
<string name="theme_mode_light">בהיר</string>
<string name="now_playing_style">סגנון 'מתנגן כעת'</string>
<string name="now_playing_style_spotify">קלאסי</string>
<string name="ai_translated">מתורגם באמצעות בינה מלאכותית</string>
<string name="lyrics_style">סגנון מילים</string>
<string name="lyrics_style_classic">קלאסי</string>
<string name="lyrics_offset">מעקף תזמון מילים</string>
<string name="lyrics_offset_message">הזז כל שורה במילים במספר הזה של מילישניות. השתמש במספר חיובי כשאתה שומע את האודיו מאוחר יותר ממה שאתה רואה אותו, שזה מה שאוזניות Bluetooth עושות, ובמספר שלילי כדי שהמילים יופיעו מוקדם יותר.</string>
<string name="lyrics_offset_value">%1$s מילישניות</string>
<string name="lyrics_offset_invalid">הזן מספר שלם של מילישניות</string>
<string name="lyrics_romanization">רומניזציה של מילים</string>
<string name="lyrics_romanization_description">הצג מילים בכתב לטיני עבור השפות שתבחר</string>
<string name="romanization_japanese_dict_downloading">מוריד את המילון היפני…</string>
<string name="romanization_japanese_dict_ready">המילון היפני הורד בהצלחה</string>
<string name="romanization_japanese_dict_failed">לא ניתן היה להוריד את המילון היפני. בדוק את החיבור ובחר יפנית שוב כדי לנסות.</string>
<string name="theme_color">צבע ערכת נושא</string>
<string name="theme_color_default">ברירת מחדל</string>
<string name="theme_color_wallpaper">טפט</string>
<string name="theme_color_custom">מותאם אישית</string>
<string name="custom_color">צבע מותאם אישית</string>
<string name="donation">אם אתה אוהב את העבודה שלי, תן ​​לי קפה</string>
<string name="relax">להירגע</string>
<string name="sleep">שינה</string>
<string name="energize">אנרגטי</string>
<string name="sad">עצוב</string>
<string name="romance">רומנטיקה</string>
<string name="feel_good">הרגשה טובה</string>
<string name="workout">אימון</string>
<string name="party">מסיבה</string>
<string name="commute">בדרכים</string>
<string name="focus">מיקוד</string>
<string name="updated">מעודכן</string>
<string name="updated_to_youtube_playlist">עודכן לפלייליסט YouTube</string>
<string name="can_t_add_to_youtube_playlist">לא ניתן היה להוסיף לפלייליסט ה-YouTube</string>
<string name="can_t_delete_from_youtube_playlist">לא ניתן היה למחוק שיר מפלייליסט ב-YouTube</string>
<string name="loading">טוען</string>
<string name="updating">מעדכן</string>
<string name="go_to_log_in_page">עבור לדף ההתחברות</string>
<string name="log_in_warning">YouTube Music מחייב אותך כעת להתחבר כדי להזרים מוזיקה, לפעמים Piped Instance לא יעבוד גם כן. עליך להתחבר ל-YouTube כדי לקבל חווית SimpMusic טובה יותר.</string>
<string name="spotify_canvas_cache">מטמון הקנבסים של Spotify</string>
<string name="clear_canvas_cache">נקה את מטמון הקנבסים</string>
<string name="listening_history">היסטוריית ההשמעה</string>
<string name="clear_listening_history">נקה את היסטוריית ההשמעה</string>
<string name="clear_listening_history_description">מחק את סטטיסטיקות ההשמעה המקומיות שלך והסר שירים ששום דבר אחר באפליקציה לא מתייחס אליהם</string>
<string name="clear_listening_history_confirm">סטטיסטיקת ההשמעה שלך והמשאבים הלא מנוצלים ינוקו. הפעולה בלתי הפיכה - נא לגבות קודם.</string>
<string name="clear_listening_history_done">היסטוריית ההשמעה נוקתה, %1$d שירים הוסרו</string>
<string name="proxy">פרוקסי</string>
<string name="proxy_description">השתמש בפרוקסי כדי לעקוף חסימת מדינה לתוכם</string>
<string name="proxy_type">סוג פרוקסי</string>
<string name="http">HTTP</string>
<string name="socks">פרוטוקול Socks</string>
<string name="proxy_host">מארח פרוקסי</string>
<string name="invalid_host">מארח לא חוקי</string>
<string name="proxy_port">יציאת פרוקסי</string>
<string name="invalid_port">פורט לא חוקי</string>
<string name="proxy_host_message">אנא הזן את מארח הפרוקסי שלך</string>
<string name="proxy_port_message">אנא הזן את יציאת הפרוקסי שלך</string>
<string name="proxy_username">שם המשתמש של הפרוקסי</string>
<string name="proxy_password">הסיסמא של הפרוקסי</string>
<string name="proxy_username_message">אנא הקלידו את שם המשתמש של הפרוקסי שלכם (אופציונלי)</string>
<string name="proxy_password_message">אנא הקלידו את הסיסמא של הפרוקסי שלכם (אופציונלי)</string>
<string name="five_seconds">חמש שניות</string>
<string name="lyrics_provider_lrc">מילים מסופקות על ידי LRCLIB</string>
<string name="sync_first">סנכרן את הפלייליסט הזה ל-YouTube Music קודם</string>
<string name="endless_queue">תור ללא הפסקה</string>
<string name="continue_playing">המשך לנגן</string>
<string name="continue_playing_endless_subtitle">מוסיף שירים דומים אוטומטית כשתור ההשמעה נגמר</string>
<string name="do_not_show_again">אל תציג שוב</string>
<string name="auto_check_for_update">בדיקת עדכונים אוטומטית</string>
<string name="auto_check_for_update_description">בודק אחר עדכון כשפותחים את האפליקציה</string>
<string name="playback_speed_pitch"><![CDATA[מהירות וגובה צליל של הנגן]]></string>
<string name="playback_speed_pitch_disabled"><![CDATA[מהירות וגובה צליל של הנגן (מבוטל כאשר קרוספייד פעיל)]]></string>
<string name="playback_speed">מהירות הניגון</string>
<string name="pitch">גובה צליל</string>
<string name="merging_audio_and_video">מערבב אודיו ווידאו</string>
<string name="error_occurred">קרתה תקלה</string>
<string name="downloading_audio">מוריד אודיו: %1$s </string>
<string name="downloading_video">מוריד וידאו: %1$s </string>
<string name="ok">אישור</string>
<string name="to_download_folder">" לתיקיית ההורדות"</string>
<string name="download_speed">מהירות ההורדה: %1$s </string>
<string name="shuffle">ערבוב</string>
<string name="other_version">גרסא אחרת</string>
<string name="later">מאוחר יותר</string>
<string name="give_a_star">סימון בכוכב</string>
<string name="enjoying_simpmusic">נהנים מאפליקציית SimpMusic</string>
<string name="if_you_enjoy_using_simpmusic_star_simpmusic_on_github_or_leave_a_review_on">אם אתם נהנים מהשימוש ב-SimpMusic, סמנו את SimpMusic בכוכב ב-GitHub או השאירו חוות דעת ב</string>
<string name="if_you_love_my_work_consider">"אם אתם אוהבים את העבודה שלי, תשקלו "</string>
<string name="buying_me_a_coffee">לקנות לי קפה.</string>
<string name="footguns_promo_title">כל מלכודת שנפלתי בה</string>
<string name="footguns_promo_message">הפרויקט עלה לי הרבה לילות של מלכודות Kotlin ו-Compose שאף רופא לא הזהיר אותי לגביהן. כתבתי כל אחת מהן, שלא תאבדו את אותם הלילות כמוני. אם זה חסך לכם אפילו לילה אחד, סימון בכוכב יראה לי הערכה רבה.</string>
<string name="fade_audio_effect">אפקט פייד לאודיו</string>
<string name="disabled">השבת</string>
<string name="fade_audio_effect_duration">אורך אפקט פייד לאודיו (מילישניות) (הגדר ל-0 כדי להשבית)</string>
<string name="duration">אורך</string>
<string name="invalid_number">מספר לא תקין</string>
<string name="shuffle_not_available">נקודת הקצה של הערבוב לא זמינה</string>
<string name="radio_not_available">נקודת הקצה של הרדיו לא זמינה</string>
<string name="visitor_data_retrieved">נתוני המבקר אוחזרו</string>
<string name="data_sync_id_retrieved">DataSyncld אוחזר</string>
<string name="retrieve_youtube_data">אחזר נתוני YouTube</string>
<string name="no_results_found">לא נמצאו תוצאות</string>
<string name="ai">בינה מלאכותית</string>
<string name="ai_provider">ספק בינה מלאכותית</string>
<string name="openai">OpenAI</string>
<string name="gemini">Gemini</string>
<string name="openai_api_compatible">OpenAI תואם מותאם אישית</string>
<string name="ai_api_key">מפתח ה-API שלך לבינה מלאכותית</string>
<string name="invalid_api_key">מפתח API לא תקין</string>
<string name="custom_ai_model_id">מזהה מודל בינה מלאכותית מותאם אישית</string>
<string name="default_models">מודלי בחירת המחדל הם "gemini-2.0-flash" ו-"gpt-4o"</string>
<string name="invalid">לא תקין</string>
<string name="custom_model_id_messages">אנא הזן את מזהה המודל המדויק מספק הבינה המלאכותית שלך.</string>
<string name="favorite_podcasts">פודקאסטים מועדפים</string>
<string name="no_favorite_podcasts">אין פודקאסטים מועדפים</string>
<string name="your_sp_dc_param_of_spotify_cookie">פרמטר ה-sp_dc של עוגיית ה-Spotify שלך</string>
<string name="your_youtube_cookie">עוגיית ה-YouTube שלך</string>
<string name="your_discord_token">טוקן ה-Discord שלך</string>
<string name="can_not_be_empty">לא יכול להיות ריק</string>
<string name="processing">מעבד</string>
<string name="website">אתר</string>
<string name="download_this_song_video_file_to_your_device">הורד את קובץ השיר/סרטון הזה למכשיר שלך</string>
<string name="kill_service_on_exit">עצור שירות ביציאה</string>
<string name="kill_service_on_exit_description">עצור את ניגון השירים ברקע כאשר יוצאים מהאפליקציה</string>
<string name="desktop_player">נגן שולחן עבודה</string>
<string name="crossfade">קרוספייד (בטא)</string>
<string name="crossfade_description">חוו מעבר חלק בין שירים על ידי עמעום השיר הנוכחי במקביל להגברת השיר הבא.</string>
<string name="crossfade_enabled">מופעל (%1$dש')</string>
<string name="crossfade_disabled">מושבת</string>
<string name="crossfade_duration">אורך קרוספייד</string>
<string name="set_crossfade_duration">הגדר את משך הזמן של הקרוספייד בשניות (%1$dש')</string>
<string name="crossfade_auto">אוטומטי</string>
<string name="crossfade_auto_description">חישוב אוטומטי של משך זמן בהתבסס על BPM (דורש זרם של 320kbps עם נתוני BPM)</string>
<string name="crossfade_dj_mode">מעבר בסגנון די-ג'יי</string>
<string name="crossfade_dj_mode_description">החל סוויפים של מסנני low-pass ו-high-pass במהלך קרוספייד למעבר חלק יותר, דמוי די-ג'יי (בדומה ל-Automix של Apple Music).</string>
<string name="auto_download_liked_songs">הורד סרטונים שאתה אוהב</string>
<string name="auto_download_liked_songs_description">הורדה אוטומטית של שיר להשמעה במצב לא מקוון בעת ​​הוספתו למועדפים, עם הגדרת איכות ההורדה שלכם. הפעלת אפשרות זו תוריד גם את השירים שכבר אהבתם. הסרת לייק לעולם לא תמחק הורדה.</string>
<string name="downloading_liked_songs">מוריד %1$d שירים מועדפים</string>
<string name="crossfade_skip_album">שמור על המשכיות האלבום</string>
<string name="crossfade_skip_album_description">דלג על הקרוספייד בין שירים באותו אלבום, כך שאלבומים שנועדו לרוץ ללא פערים עדיין יפעלו. המעבר החוצה מהאלבום עדיין עובר קרוספייד.</string>
<string name="youtube_subtitle_language">שפת תרגום כתוביות של YouTube</string>
<string name="youtube_subtitle_language_message">הקלד קוד שפה (לדוגמה: en) (2 אותיות). זוהי שפת התרגום המועדפת לכתוביות YouTube.</string>
<string name="preferred_audio_language">שפת שמע מועדפת</string>
<string name="preferred_audio_language_message">חלק מהסרטונים, בעיקר פודקאסטים, מגיעים עם מספר רצועות שמע בשפות שונות. הקלד קוד שפה (לדוגמה: vi) (2 אותיות) כדי להשמיע רצועה זו כאשר היא קיימת. השאר ריק כדי להשמיע תמיד את האודיו המקורי.</string>
<string name="original_audio">אודיו מקורי</string>
<string name="use_anonymous">השתמש בשם אנונימי כדי לתרום</string>
<string name="contributor_name">שם התורם</string>
<string name="contributor_email">אימייל התורם</string>
<string name="anonymous">אנונימי</string>
<string name="simpmusic_lyrics">מילים של SimpMusic</string>
<string name="lyrics_provider_simpmusic">מילים מסופקות על ידי SimpMusic Lyrics</string>
<string name="backup_in_progress">גיבוי בתהליך</string>
<string name="restore_in_progress">שחזור בתהליך</string>
<string name="backup_downloaded">גבה מידע שהורד</string>
<string name="backup_downloaded_description">גבה מידע על כל השירים שהורדו</string>
<string name="auto_backup">גיבוי אוטומטי</string>
<string name="auto_backup_description">גבה אוטומטית את הנתונים שלך לתיקיית הורדות/SimpMusic</string>
<string name="backup_frequency">תדירות גיבוי</string>
<string name="daily">יומי</string>
<string name="weekly">שבועי</string>
<string name="monthly">חודשי</string>
<string name="keep_backups">שמור גיבויים</string>
<string name="keep_backups_format">שמור את %1$s הגיבויים האחרונים</string>
<string name="last_backup">גיבוי אחרון</string>
<string name="never">לעולם לא</string>
<string name="auto_backup_success">הגיבוי האוטומטי הושלם בהצלחה</string>
<string name="auto_backup_failed">הגיבוי האוטומטי נכשל</string>
<string name="no_playlist_found">לא נמצאו פלייליסטים מקומיים, אנא צור אחד קודם</string>
<string name="list_all_cookies_of_this_page">פרט את כל העוגיות של דף זה</string>
<string name="copied_to_clipboard">הועתק ללוח</string>
<string name="update_channel">ערוץ עדכונים</string>
<string name="enable_liquid_glass_effect">הפעל זכוכית נוזלית (בטא)</string>
<string name="enable_liquid_glass_effect_description">הפעל את אפקט הזכוכית הנוזלית של iOS בחלק מהמקומות (נדרש Android 13 ומעלה)</string>
<string name="your_library">הספרייה שלך</string>
<string name="play_explicit_content">נגן תוכן מפורש</string>
<string name="play_explicit_content_description">הפעל כדי לנגן תוכן מפורש</string>
<string name="explicit_content_blocked">מדלג על תוכן מפורש</string>
<string name="mix_for_you">מיקס בשבילך</string>
<string name="mix">מיקס</string>
<string name="no_mixes_found">לא נמצאו מיקסים</string>
<string name="no_charts_found">לא נמצאו תרשימים</string>
<string name="desktop_webview_description">עקב המגבלות של Compose Multiplatform, איני יכול ליישם WebView במחשב שולחני.\nקרא את זה כדי ללמוד עוד כיצד להתחבר במחשב שולחני:\n</string>
<string name="open_blog_post">פתח את פוסט הבלוג</string>
<string name="discord_integration">חיבור Discord</string>
<string name="log_in_to_discord">התחבר ל-Discord</string>
<string name="intro_login_to_discord">התחבר כדי להפעיל את תכונת הנוכחות העשירה של Discord</string>
<string name="enable_rich_presence">הפעל נוכחות עשירה</string>
<string name="rich_presence_info">הצג מידע על שסן המדיה בפרופיל ה-Diacord שלך</string>
<string name="log_out_from_discord">התנתק מ-Discord</string>
<string name="lastfm_integration">Last.fm</string>
<string name="log_in_to_lastfm">התחבר ל-Last.fm</string>
<string name="log_out_from_lastfm">התנתק מ-Last.fm</string>
<string name="log_out_confirm_message">תצטרך להתחבר שוב כדי להשתמש בתכונה הזו.</string>
<string name="intro_login_to_lastfm">התחבר כדי לתעד את ההאזנות שלך</string>
<string name="enable_scrobbling">הפעל תיעוד</string>
<string name="scrobbling_info">שלח את מה שאתה מאזין לו לפרופיל ה-Last.fm שלך</string>
<string name="lastfm_login_step_1">פתח את עמוד ה-Last.fm והענק ל-SimpMusic גישה לחשבון שלך.</string>
<string name="lastfm_login_step_2">SimpMusic יפתח שוב מעצמו לאחר שתעניק גישה.</string>
<string name="lastfm_open_authorize_page">פתח את Last.fm</string>
<string name="lastfm_login_failed">לא ניתן היה להתחבר אל Last.fm. אנא נסה שוב.</string>
<string name="lastfm_paste_callback_title">לא חזרת אוטומטית?</string>
<string name="lastfm_paste_callback_hint">הדבר את הקישור שהדפדפן שלך הועבר אליו</string>
<string name="lastfm_paste_callback_confirm">המשך עם הקישור הזה</string>
<string name="logged_in_as">מחובר כ-%1$s</string>
<string name="youtube_liked_music">מוזיקה שאהבת ב-YouTube</string>
<string name="keep_your_youtube_playlist_offline">המשך להציג את פלייליסט ה-YouTube במצב לא מקוון</string>
<string name="keep_your_youtube_playlist_offline_description">שמור את פלייליסט ה-YouTube במצב מקומי והמשך להציג אותו במצב לא מקוון</string>
<string name="combine_local_and_youtube_liked_songs">החלף את 'מועדפים' ב'אהבת ב-YouTube'</string>
<string name="combine_local_and_youtube_liked_songs_description">השתמש רק ב'אהבת ב-YouTube' (לא תומך בלעשות לייק לשיר כאשר נמצאים במצב לא מקוון)</string>
<string name="this_year">השנה הזאת</string>
<string name="last_90_days">90 הימים האחרונים</string>
<string name="last_30_days">30 הימים האחרונים</string>
<string name="last_7_days">7 הימים האחרונים</string>
<string name="lower_plays">ניגונים</string>
<string name="top_song">שיר מוביל</string>
<string name="songs_played">שירים שנוגנו</string>
<string name="total_listened_time">סך הכל שירים ששמעת</string>
<string name="seconds">שניות</string>
<string name="listening_time_hours_minutes">%1$d שעות ו-%2$d דקות</string>
<string name="listening_time_minutes">%1$d דקות</string>
<string name="listening_time_seconds">%1$d שניות</string>
<string name="listened_time">זמן האזנה</string>
<string name="month_short_jan">ינואר</string>
<string name="month_short_feb">פברואר</string>
<string name="month_short_mar">מרץ</string>
<string name="month_short_apr">אפריל</string>
<string name="month_short_may">מאי</string>
<string name="month_short_jun">יוני</string>
<string name="month_short_jul">יולי</string>
<string name="month_short_aug">אוגוסט</string>
<string name="month_short_sep">ספטמבר</string>
<string name="month_short_oct">אוקטובר</string>
<string name="month_short_nov">נובמבר</string>
<string name="month_short_dec">דצמבר</string>
<string name="analytics_quick_facts">עובדות מהירות</string>
<string name="analytics_music_ratio">יחס מוזיקה</string>
<string name="analytics_fingerprint">טביעת האזנה</string>
<string name="analytics_listening_clock">שעון האזנה</string>
<string name="analytics_by_decade">מוזיקה לפי עשור</string>
<string name="analytics_vs_previous">לעומת תקופה קודמת</string>
<string name="analytics_no_previous">אין תקופה קודמת</string>
<string name="analytics_previous_was">לשעבר %1$s</string>
<string name="analytics_avg_per_day">ממוצע ליום</string>
<string name="analytics_busiest_day">היום הפעיל ביותר</string>
<string name="analytics_busiest_hour">השעה העמוסה ביותר</string>
<string name="analytics_plays_in_hour">ניגונים בשעה הזו</string>
<string name="analytics_no_data_period">אין האזנות בתקופה הזו. השתמש בחצים כדי להסתכל על תקופה אחרת.</string>
<string name="analytics_songs">שירים</string>
<string name="analytics_this_period">תקופה זו</string>
<string name="analytics_previous_period">תקופה קודמת</string>
<string name="analytics_decade_coverage">בהתבסס על %1$s ההשמעות המציגות מידע על אלבומים - לשירים שהושמעו מהרדיו או כסרטונים עצמאיים אין שנת יציאה.</string>
<string name="analytics_decade_pre">לפני %1$d</string>
<string name="analytics_axis_consistency">עקביות</string>
<string name="analytics_axis_discovery">גילוי</string>
<string name="analytics_axis_diversity">גיוון</string>
<string name="analytics_axis_concentration">ריכוז</string>
<string name="analytics_axis_replay">הפעלה חוזרת</string>
<string name="your_recently_played">הושמעו לאחרונה</string>
<string name="your_top_artists">האמנים המובילים שלך</string>
<string name="your_top_albums">האלבומים המובילים שלך</string>
<string name="your_top_tracks">השירים המובילים שלך</string>
<string name="date_range">טווח תאריכים</string>
<string name="no_data_analytics">אנא נגן קצת מוזיקה כדי לראות נתונים</string>
<string name="rate_translated_lyrics">דרג תרגום</string>
<string name="rate_lyrics">דרג מילים</string>
<string name="vote_for_lyrics">הצבע למילים</string>
<string name="upvote">הצבעה חיוביות</string>
<string name="downvote">הצבעה שלילית</string>
<string name="vote_submitted">תודה שהצבעת למילים האלו!</string>
<string name="vote_error">השליחה נכשלה</string>
<string name="open_app">פתח את SimpMusic</string>
<string name="close_miniplayer">סגור את המיני-נגן</string>
<string name="open_miniplayer">פתח את המיני-נגן</string>
<string name="quit_app">סגור אפליקציה</string>
<string name="local_tracking_title">היסטוריית האזנה למעקב מקומי</string>
<string name="local_tracking_description">רשום את היסטוריית ההאזנה שלך למסד הנתונים המקומי</string>
<string name="blog_notification_title">עדכוני פוסט הבלוג</string>
<string name="blog_notification_description">קבל התראה כאשר פוסט בלוג חדש מפורסם</string>
<string name="simpmusic_charts">מצעדי SimpMusic</string>
<string name="crossfading">מערבב</string>
<string name="your_320kbps_url">קישור ה-320kbps שלך</string>
<string name="your_320kbps_url_description">השאר ריק כדי לבחור אוטומטית את הקישור העובד מבודק זמן הפעילות.</string>
<string name="search_for">חפש עבור</string>
<string name="synced_playlist_cannot_change_order">לא ניתן לשנות את סדר הפלייליסט המסונכרן</string>
<string name="bpm">פעימות לדקה</string>
<string name="key">מפתח</string>
<string name="scale">סולם</string>
<string name="home_offline_title">לא ניתן להתחבר</string>
<string name="home_offline_subtitle">בדוק את חיבור האינטרנט שלך ונסה שוב, או האזן למוזיקה שהורדת.</string>
<string name="listen_to_downloaded">האזן למוזיקה שהורדת</string>
<string name="playing_on_device">מתנגן ב-%1$s</string>
<string name="not_available_while_casting">לא זמין בזמן שידור</string>
<string name="audio_output">פלט אודיו</string>
<string name="audio_output_playing_here">מתנגן כאן</string>
<string name="audio_output_cast">שדר</string>
<string name="audio_output_system_default">ברירת המחדל של המערכת</string>
<string name="audio_output_wired">אוזניות חוטיות</string>
<string name="audio_output_bluetooth">Bluetooth</string>
<string name="audio_output_usb">אודיו USB</string>
<string name="audio_output_hdmi">HDMI</string>
<string name="import_data">יבא פלייליסטים</string>
<string name="import_playlists_from_other_apps">יבא פלייליסטים מאפליקציה אחרת</string>
<string name="import_data_intro">ייבוא מביא רשימות השמעה שהומרו מ-Spotify או מאפליקציית לקוח אחרת של YouTube Music.\nכלי המרה: https://www.simpmusic.org/tools\nב-SimpMusic, ניתן לייבא רק קבצי פלייליסט בפורמט שלנו</string>
<string name="import_reading_file">קורא קובץ…</string>
<string name="import_progress_songs">%1$d מתוך %2$d שירים</string>
<string name="import_result">נוצרו %1$d פלייליסטים ויובאו %2$d שירים.</string>
<string name="import_result_skipped">%1$d רשומות דולגו מכיוון שהשיר שלהן היה חסר בקובץ.</string>
<string name="import_failed">ייבוא נכשל</string>
<string name="import_invalid_file">הקובץ הזה הוא לא קובץ ייבוא תקין של SimpMusic. המר את הספרייה שלך ב-simpmsuic.org תחילה.</string>
<string name="analytics">נתונים</string>
<string name="select_all">בחר הכל</string>
<string name="n_selected">%1$d נבחרו</string>
<string name="n_songs_selected">%1$d שירים נבחרו</string>
<string name="max_selection_reached">ניתן לבחור עד %1$d שירים בו זמנית</string>
<string name="exit_selection">צא מהבחירה</string>
<string name="remove_from_playlist">הסר מהפלייליסט</string>
<!-- Listen Together -->
<string name="listen_together">האזן ביחד</string>
<string name="lt_tagline">שיר אחד, זמן אחד, ביחד. צור חדר ושתף את הקוד עם חברים.</string>
<string name="lt_not_connected">לא מחובר</string>
<string name="lt_connected">מחובר</string>
<string name="lt_connecting">מתחבר…</string>
<string name="lt_connect">מחובר</string>
<string name="lt_display_name">שם תצוגה</string>
<string name="lt_display_name_hint">השם שכולם בחדר רואים</string>
<string name="lt_create_room">צור חדר</string>
<string name="lt_or_join_with_code">או הצטרף עם קוד</string>
<string name="lt_join_room">הצטרף לחדר</string>
<string name="lt_background_warning">סגירת האפליקציה או מעבר ממנה ליותר מידי זמן תנתק אותך מהחדר.</string>
<string name="lt_disconnect">התנתק</string>
<string name="lt_room_code">קוד חדר</string>
<string name="lt_in_room">בחדר</string>
<string name="lt_host_badge">מארח</string>
<string name="lt_you">אתה</string>
<string name="lt_join_requests">מבקש להצטרף</string>
<string name="lt_just_asked">זה עתה ביקש להצטרף</string>
<string name="lt_suggestions">שירים מומלצים</string>
<string name="lt_leave_room">עזוב חדר</string>
<string name="lt_credit_protocol">פרוטוקול האזנה משותפת של Metrolist</string>
<string name="lt_credit_compatible">החדרים תואמים ללקוחות Metrolist</string>
<string name="lt_server">שרת</string>
<string name="lt_custom_server">שרת מותאם אישית</string>
<string name="lt_custom_server_desc">הזן כתובת wss:// משלך</string>
<string name="lt_save_server">שמור שרת</string>
<string name="lt_as_host">כאשר אתה מארח</string>
<string name="lt_auto_approve_joins">אשר הזמנות אוטומטית</string>
<string name="lt_auto_approve_joins_desc">כל אחד עם הקוד יכול להצטרף מבלי לשאול</string>
<string name="lt_blocked">חסומים</string>
<string name="lt_blocked_empty">אף אחד לא חסום</string>
<string name="lt_unblock">בטל חסימה</string>
<string name="lt_transfer_host">הפוך למארח</string>
<string name="lt_transfer_host_desc">הם ישלטו על הניגון</string>
<string name="lt_kick">מחק מהחדר</string>
<string name="lt_kick_desc">הם יכולים להצטרף מחדש עם הקוד</string>
<string name="lt_block">חסום לצמיתות</string>
<string name="lt_block_desc">הם לא יוכלו להצטרף, אפילו עם הקוד</string>
<string name="lt_waiting_approval">ממתין למארח</string>
<string name="lt_waiting_approval_desc">הם צריכים לאשר אותך. אם הקוד שגוי תקבל שגיאה במקום.</string>
<string name="lt_cancel_join">בטל</string>
<string name="share_lyrics">שתף מילים</string>
<string name="share_lyrics_select_title">בחר שורות</string>
<string name="share_lyrics_selected_count">%1$d שורות נבחרו</string>
<string name="share_lyrics_continue">המשך</string>
<string name="share_lyrics_max_reached">אתה יכול לשתף עד %1$d שורות</string>
<string name="share_lyrics_save">שמור תמונה</string>
<string name="share_lyrics_share_action">שתף</string>
<string name="share_lyrics_saved">נשמר בתמונות שלך</string>
<string name="share_lyrics_saved_desktop">נשמר בתיקיית ההורדות שלך</string>
<string name="share_lyrics_save_failed">לא ניתן היה לשמור את התמונה</string>
<string name="share_lyrics_share_failed">לא ניתן היה לשתף את התמונה</string>
<string name="share_lyrics_background">רקע</string>
<string name="share_lyrics_permission_denied">הרשאת אחסון נדרשת כדי לשמור את התמונה</string>
<string name="share_lyrics_no_lyrics">לשיר הזה אין מילים לשתף</string>
<string name="wrapped_tap_to_begin">לחץ כדי להתחיל</string>
<string name="wrapped_tap_to_continue">לחץ כדי להמשיך</string>
<string name="wrapped_share">שתף</string>
<string name="wrapped_save">שמור</string>
<string name="wrapped_opening_title">השנה שלך במוזיקה</string>
<string name="wrapped_opening_lead">פתחת את SimpMusic ב</string>
<string name="wrapped_opening_days">%1$s מתוך %2$s ימים</string>
<string name="wrapped_opening_tracks">%1$s שירים הופעלו.</string>
<string name="wrapped_minutes_title">בילית חלק זה של השנה בהאזנה</string>
<string name="wrapped_minutes_unit">דקות</string>
<string name="wrapped_minutes_whole_days">%1$s ימים שלמים.</string>
<string name="wrapped_minutes_caption">כל מה שעזבת בו לפני החמישית הראשונה לא נחשב - זה הזמן שנשארת בפועל.</string>
<string name="wrapped_tracks_title">החמישיה שלא יכולת להשאיר לבד</string>
<string name="wrapped_plays">%1$s ניגונים</string>
<string name="wrapped_artists_title">המקום הראשון שלך, בפער</string>
<string name="wrapped_artists_two_years">אמן מוביל שנתיים ברציפות</string>
<string name="wrapped_albums_title">התקליטים שחזרת אליהם שוב ושוב</string>
<string name="wrapped_clock_title">כאשר המוזיקה באמת התרחשה</string>
<string name="wrapped_clock_peak_hour">שעת השיא</string>
<string name="wrapped_clock_you_are">אתה</string>
<string name="wrapped_clock_band_night">מאזין לילה</string>
<string name="wrapped_clock_band_morning">מאזין בוקר</string>
<string name="wrapped_clock_band_afternoon">מאזין אחר צהריים</string>
<string name="wrapped_clock_band_evening">מאזין ערב</string>
<string name="wrapped_clock_caption">%1$s ממה שניגנת היה בין %2$s ל-%3$s.</string>
<string name="wrapped_biggest_day_title">יום אחד ניצח את כל האחרים</string>
<string name="wrapped_biggest_day_plays">האזנות — נגד ממוצע טיפוסי של %1$s</string>
<string name="wrapped_biggest_day_caption">האזנת %1$s%2$s פעמים ביום הזה.</string>
<string name="wrapped_type_title">חמש קריאות על אופן ההאזנה שלך</string>
<string name="wrapped_type_the">ה</string>
<string name="wrapped_archetype_regular">רגיל</string>
<string name="wrapped_archetype_explorer">חוקר</string>
<string name="wrapped_archetype_omnivore">טורף</string>
<string name="wrapped_archetype_devotee">מסור</string>
<string name="wrapped_archetype_deep_diver">צוללן</string>
<string name="wrapped_archetype_regular_desc">%1$s מהשנה הייתה מוזיקה. לא עושים בינג' למוזיקה — פשוט משאירים אותה דלוקה.</string>
<string name="wrapped_archetype_explorer_desc">%1$s מהאמנים ששמעת היו חדשים לך. המשכת ללכת.</string>
<string name="wrapped_archetype_omnivore_desc">ההאזנות שלך התפרסו על פני %1$s אמנים, אף אחד מהם לא היה אחראי.</string>
<string name="wrapped_archetype_devotee_desc">%1$s מהשנה שלך הלכה לחמישה אמנים. אתה יודע בדיוק מה אתה אוהב.</string>
<string name="wrapped_archetype_deep_diver_desc">האזנת מחדש ל-%1$s מההאזנות שלך. אתה מוצא שיר ואתה נשאר בשבילו.</string>
<string name="wrapped_decades_title">עד כמה רחוק הלכת</string>
<string name="wrapped_decades_caption">נלקח מתוך %1$s האזנות שאלבומם נשא שנה. רדיו וסרטונים עצמאיים אינם נושאים שנה, לכן הם אינם מופיעים כאן.</string>
<string name="wrapped_share_title">שנה שלמה, תמונה אחת</string>
<string name="wrapped_share_card_title">השנה שלי במוזיקה</string>
<string name="wrapped_share_top_tracks">שירים מובילים</string>
<string name="wrapped_share_top_artists">אמנים מובילים</string>
<string name="wrapped_share_top_album">אלבום מוביל</string>
<string name="wrapped_share_minutes">דקות</string>
<string name="wrapped_share_artists">אמנים</string>
<string name="wrapped_share_days">ימים</string>
<string name="wrapped_entry_title">סיכום %1$s שלך מוכן</string>
<string name="wrapped_entry_subtitle">%1$s דקות, %2$s אמנים, ואחד מועדף ברור מאוד.</string>
<string name="wrapped_not_enough_title">לא מספיק השנה</string>
<string name="wrapped_not_enough_body">'סיכום' דורש בערך חודש של האזנה לפני שהוא יספר משהו. יש לך %1$s עד עכשיו.</string>
<string name="wrapped_not_enough_days">%1$s ימים</string>
<string name="wrapped_not_enough_progress">%1$s מתוך %2$s ימים</string>
<string name="wrapped_open_analytics">פתח את 'נתונים' במקום</string>
<string name="wrapped_recap_month">סיכום %1$s</string>
<string name="wrapped_recap_month_year">סיכום %1$s%2$s</string>
<string name="wrapped_recap_subtitle">השירים המובילים שלך החודש</string>
<string name="wrapped_recap_empty">לא מספיק החודש</string>
<string name="month_full_jan">ינואר</string>
<string name="month_full_feb">פברואר</string>
<string name="month_full_mar">מרץ</string>
<string name="month_full_apr">אפריל</string>
<string name="month_full_may">מאי</string>
<string name="month_full_jun">יוני</string>
<string name="month_full_jul">יולי</string>
<string name="month_full_aug">אוגוסט</string>
<string name="month_full_sep">ספטמבר</string>
<string name="month_full_oct">אוקטובר</string>
<string name="month_full_nov">נובמבר</string>
<string name="month_full_dec">דצמבר</string>
<string name="login_sync_section">מכשירים</string>
<string name="login_sync_desktop_title">התחבר מהטלפון שלך</string>
<string name="login_sync_desktop_description">סרוק קוד QR עם SimpMusic ב-Android כדי להביא את הכניסות שלך לכאן</string>
<string name="login_sync_android_title">שלח כניסות למחשב</string>
<string name="login_sync_android_description">סרוק את קוד ה-QR שמופיע ב-SimpMusic במחשב שלך</string>
<string name="login_sync_desktop_instructions">בטלפון שלך, פתח את הגדרות SimpMusic, עבור אל %1$s וסרוק את הקוד הזה.</string>
<string name="login_sync_pick_address">בחר כתובת שהטלפון שלך יוכל לגשת אליה — באותה הרשת, או דרך Tailscale.</string>
<string name="login_sync_phone_connected">טלפון מחובר. אשר בטלפון שלך כדי להמשיך.</string>
<string name="login_sync_received">מחובר אל %1$s</string>
<string name="login_sync_expired">הקוד הזה פג תוקף.</string>
<string name="login_sync_new_code">קוד חדש</string>
<string name="login_sync_no_network">לא נמצאה כתובת רשת במחשב הזה.</string>
<string name="login_sync_start_failed">לא ניתן היה להתחיל: %1$s</string>
<string name="login_sync_receive_failed">לא ניתן היה לקבל את הכניסות: %1$s</string>
<string name="login_sync_scan_hint">כוון את המצלמה לקוד ה-QR במחשב שלך</string>
<string name="login_sync_connecting">מתחבר למחשב שלך…</string>
<string name="login_sync_trust_title">לסמוך על המחשב הזה?</string>
<string name="login_sync_trust_message">%1$s (%2$s) מבקש את הכניסות שלך. אנא המשך רק אם המחשב הזה נמצא לפניך.</string>
<string name="login_sync_trust">סמוך</string>
<string name="login_sync_choose_title">בחר מה לשלוח</string>
<string name="login_sync_send">שלח</string>
<string name="login_sync_sending">שולח...</string>
<string name="login_sync_sent">מחובר ב-%1$s: %2$s</string>
<string name="login_sync_nothing_signed_in">אתה לא מחובר לאף שירות עדיין.</string>
<string name="login_sync_camera_needed">SimpMusic משתמש במצלמה שלך רק כדי לסרוק את הקוד שמוצג במחשב שלך.</string>
<string name="login_sync_allow_camera">אשר גישה למצלמה</string>
<string name="login_sync_camera_blocked">גישה למצלמה כבויה עבור SimpMusic. הפעל אותה בהגדרות כדי לסרוק את הקוד.</string>
<string name="login_sync_open_settings">פתח הגדרות</string>
<string name="login_sync_no_camera">אין מצלמה זמינה לסריקת הקוד.</string>
<string name="login_sync_unreachable">לא ניתן היה להתחבר למחשב. ודא שהטלפון שלך מחובר לאותה רשת או רשת אחורית, ולאחר מכן נסה שוב.</string>
<string name="login_sync_code_expired">הקוד במחשב שלך פג. הצג אחד חדש וסרוק אותו שוב.</string>
<string name="login_sync_failed">משהו השתבש. אנא נסה שוב.</string>
<string name="modified_build_title">אפליקציה לא רשמית</string>
<string name="modified_build_message">אתה משתמש בעותק לא רשמי של SimpMusic ששונה או נארז מחדש על ידי מישהו אחר. לא ניתן להשתמש בו. הורד את הגרסה הרשמית מהאתר simpmusic.org.</string>
<string name="taste_title">מהו טעם המוזיקה שלך?</string>
<string name="taste_invite">תן לבינה מלאכותית לקרוא את 12 החודשים האחרונים של ההאזנות שלך ולספר לך.</string>
<string name="taste_loading_title">קורא את טעם המוזיקה שלך…</string>
<string name="taste_loading">בינה מלאכותית עוברת על מה שהאזנת לו ב-12 בחודשים האחרונים.</string>
<string name="taste_not_enough_data">דרושים %1$s ימי האזנה ב-12 החודשים האחרונים, יש לך %2$s עד כה.</string>
<string name="taste_not_configured_title">בינה מלאכותית לא מוגדרת עדיין</string>
<string name="taste_not_configured">הוסף מפתח API בהגדרות > בינה מלאכותית כדי שהבינה מלאכותית תוכל לקרוא את טעמי המוזיקה שלך.</string>
<string name="taste_error_title">הבינה המלאכותית דיווחה על שגיאה</string>
<string name="taste_error_generic">הבינה המלאכותית לא הביאה תשובה יציבה. אנא נסה שוב בעוד רגע.</string>
<string name="taste_error_with_code">%1$s (שגיאה %2$s)</string>
<string name="taste_eyebrow">טעם המוזיקה שלך • 12 החודשים האחרונים</string>
<string name="taste_created_on">נוצר ב-%1$s</string>
<string name="taste_regenerate">צור מחדש</string>
<string name="taste_share_title">שתף את טעם המוזיקה שלך</string>
<string name="taste_card_title">טעם המוזיקה שלי</string>
<string name="taste_card_subtitle">12 חודשים ב-SimpMusic</string>
<string name="receipt_open">קבלת האזנה</string>
<string name="receipt_share_title">שתף את הקבלה שלך</string>
<string name="receipt_order">הזמנה #%1$s עבור %2$s</string>
<string name="receipt_order_anonymous">הזמנה #%1$s</string>
<string name="receipt_date">%1$s,%2$s%3$s,%4$s</string>
<string name="receipt_qty">כמות</string>
<string name="receipt_item">פריט</string>
<string name="receipt_amount">סכום</string>
<string name="receipt_item_count">כמות פריט:</string>
<string name="receipt_total">סך הכל:</string>
<string name="receipt_card">כרטיס #: **** **** **** %1$s</string>
<string name="receipt_auth_code">קוד אבטחה: %1$s</string>
<string name="receipt_cardholder">מחזיק הכרטיס: %1$s</string>
<string name="receipt_thanks">תודה שהאזנת!</string>
<string name="receipt_entry_title">קבלת ההאזנה שלך</string>
<string name="receipt_entry_subtitle">%1$s, הודפס וזמין לשיתוף.</string>
<string name="move_to_play_next">העבר להפעלה הבאה</string>
<string name="custom_base_url">כתובת אתר בסיס מותאמת אישית</string>
<string name="base_url">כתובת אתר בסיסית</string>
<string name="invalid_url_format">כתובת אתר בסיסית לא חוקית</string>
<string name="custom_base_url_message">הזן כתובת אתר בסיסית של API תואם OpenAl (לדוגמה %1$s)</string>
<string name="custom_headers">כותרות מותאמות אישית</string>
<string name="configured">הוגדר</string>
<string name="not_set">לא מוגדר</string>
<string name="custom_headers_json">כותרות מותאמות אישית (JSON)</string>
<string name="headers_json">כותרות JSON</string>
<string name="invalid_json_format">פורמט JSON לא חוקי</string>
<string name="custom_headers_message">הזן כותרות מותאמות אישית בפורמט JSON:</string>
<string name="weekday_monday">שני</string>
<string name="weekday_tuesday">שלישי</string>
<string name="weekday_wednesday">רביעי</string>
<string name="weekday_thursday">חמישי</string>
<string name="weekday_friday">שישי</string>
<string name="weekday_saturday">שבת</string>
<string name="weekday_sunday">ראשון</string>
<string name="developer_option">אפשרות מפתח</string>
<string name="app_log">רשומות אפליקציה</string>
<string name="app_log_description">הצג, סנן ויצא את רשומות האפליקציה</string>
<string name="log_errors">שגיאות</string>
<string name="log_search_hint">חפש תג או הודעה</string>
<string name="log_level_info">מידע</string>
<string name="log_level_warn">אזהרה</string>
<string name="log_live">בשידור חי • %1$s שורות</string>
<string name="log_paused">הושהה • %1$s שורות</string>
<string name="log_empty">שום דבר לא נרשם עדיין</string>
<string name="log_no_stack_trace">אין עקבות מחסנית</string>
<string name="log_stack_trace_lines">עקבות מחסנית • %1$s שורות</string>
<string name="log_hide_stack_trace">הסתר עקבות מחסנית</string>
<string name="log_export_failed">לא ניתן היה לייצא את הרשומות</string>
</resources>

View file

@ -25,6 +25,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
@ -65,6 +66,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.kmpalette.palette.graphics.Palette
import com.maxrave.domain.data.model.ui.ScreenSizeInfo
@ -821,6 +823,16 @@ fun PaddingValues.copy(
)
}
/**
* Lays [content] out left-to-right even in a right-to-left language. For playback controls, seek
* bars and anything else that shows which way time runs: those follow the timeline, not the
* reading direction, so ⏮ stays left of ⏭ and a progress bar fills from the left.
*/
@Composable
fun LeftToRight(content: @Composable () -> Unit) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr, content = content)
}
fun ImageBitmap.toResizedBitmap(
width: Int,
height: Int,

View file

@ -90,6 +90,7 @@ import com.maxrave.simpmusic.expect.ui.isLyricsBlurSupported
import com.maxrave.simpmusic.expect.ui.layerBackdrop
import com.maxrave.simpmusic.expect.ui.rememberBackdrop
import com.maxrave.simpmusic.extension.KeepScreenOn
import com.maxrave.simpmusic.extension.LeftToRight
import com.maxrave.simpmusic.extension.formatDuration
import com.maxrave.simpmusic.extension.lengthLabel
import com.maxrave.simpmusic.extension.getScreenSizeInfo
@ -500,243 +501,245 @@ fun FullscreenLyricsContent(
}
// Progress Bar and Time - Always visible
Column {
// Real Slider
Box(
Modifier
.padding(
top = 15.dp,
).padding(horizontal = 40.dp),
) {
LeftToRight {
Column {
// Real Slider
Box(
modifier =
Modifier
.fillMaxWidth()
.height(24.dp),
contentAlignment = Alignment.Center,
Modifier
.padding(
top = 15.dp,
).padding(horizontal = 40.dp),
) {
Crossfade(timelineState.loading) {
if (it) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
LinearProgressIndicator(
modifier =
Modifier
.fillMaxWidth()
.height(4.dp)
.padding(
horizontal = 3.dp,
).clip(
RoundedCornerShape(8.dp),
),
color = Color.Gray,
trackColor = Color.DarkGray,
strokeCap = StrokeCap.Round,
)
}
} else {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
LinearProgressIndicator(
progress = { timelineState.bufferedPercent.toFloat() / 100 },
modifier =
Modifier
.fillMaxWidth()
.height(4.dp)
.padding(
horizontal = 3.dp,
).clip(
RoundedCornerShape(8.dp),
),
color = Color.Gray,
trackColor = Color.DarkGray,
strokeCap = StrokeCap.Round,
drawStopIndicator = {},
)
}
}
}
}
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Slider(
// Fraction, not 0..100 — see the note in NowPlayingScreen:
// material3 alpha25 drops valueRange on its binary-compatibility
// overload.
value = sliderValue / 100f,
onValueChange = {
sharedViewModel.onUIEvent(
UIEvent.UpdateProgress(it * 100f),
)
},
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(top = 3.dp)
.align(
Alignment.TopCenter,
),
track = { sliderState ->
SliderDefaults.Track(
modifier =
Modifier
.height(5.dp),
enabled = true,
sliderState = sliderState,
colors =
SliderDefaults.colors().copy(
thumbColor = sliderTrackColor,
activeTrackColor = sliderTrackColor,
inactiveTrackColor = Color.Transparent,
),
thumbTrackGapSize = 0.dp,
drawTick = { _, _ -> },
drawStopIndicator = null,
)
},
thumb = {
SliderDefaults.Thumb(
modifier =
Modifier
.height(18.dp)
.width(8.dp)
.padding(
vertical = 4.dp,
),
thumbSize = DpSize(8.dp, 8.dp),
interactionSource =
remember {
MutableInteractionSource()
},
colors =
SliderDefaults.colors().copy(
thumbColor = Color.White,
activeTrackColor = Color.White,
inactiveTrackColor = Color.Transparent,
),
enabled = true,
)
},
)
}
}
LazyColumn {
item {
// Time Layout
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp),
.height(24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = if (timelineState.isLive) "" else formatDuration(timelineState.current),
style = typo().bodyMedium,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Left,
)
AnimatedVisibility(
enter = fadeIn(),
exit = fadeOut(),
visible = timelineState.isCrossfading,
) {
Text(
text = stringResource(Res.string.crossfading),
style = typo().bodyMedium,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
)
}
Text(
text = timelineState.lengthLabel(),
style = typo().bodyMedium,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Right,
)
}
Spacer(
modifier =
Modifier
.fillMaxWidth()
.height(5.dp),
)
}
item {
// Control Buttons - Animated visibility
AnimatedVisibility(
visible = showControlButtons,
enter =
expandVertically(
tween(300),
),
exit =
shrinkVertically(
tween(300),
),
) {
PlayerControlLayout(controllerState) {
sharedViewModel.onUIEvent(it)
Crossfade(timelineState.loading) {
if (it) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
LinearProgressIndicator(
modifier =
Modifier
.fillMaxWidth()
.height(4.dp)
.padding(
horizontal = 3.dp,
).clip(
RoundedCornerShape(8.dp),
),
color = Color.Gray,
trackColor = Color.DarkGray,
strokeCap = StrokeCap.Round,
)
}
} else {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
LinearProgressIndicator(
progress = { timelineState.bufferedPercent.toFloat() / 100 },
modifier =
Modifier
.fillMaxWidth()
.height(4.dp)
.padding(
horizontal = 3.dp,
).clip(
RoundedCornerShape(8.dp),
),
color = Color.Gray,
trackColor = Color.DarkGray,
strokeCap = StrokeCap.Round,
drawStopIndicator = {},
)
}
}
}
}
AnimatedVisibility(
visible = showControlButtons,
enter =
expandVertically(
tween(300),
),
exit =
shrinkVertically(
tween(300),
),
) {
// List Bottom Buttons
Box(
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Slider(
// Fraction, not 0..100 — see the note in NowPlayingScreen:
// material3 alpha25 drops valueRange on its binary-compatibility
// overload.
value = sliderValue / 100f,
onValueChange = {
sharedViewModel.onUIEvent(
UIEvent.UpdateProgress(it * 100f),
)
},
modifier =
Modifier
.height(32.dp)
.fillMaxWidth()
.padding(horizontal = 40.dp),
.padding(top = 3.dp)
.align(
Alignment.TopCenter,
),
track = { sliderState ->
SliderDefaults.Track(
modifier =
Modifier
.height(5.dp),
enabled = true,
sliderState = sliderState,
colors =
SliderDefaults.colors().copy(
thumbColor = sliderTrackColor,
activeTrackColor = sliderTrackColor,
inactiveTrackColor = Color.Transparent,
),
thumbTrackGapSize = 0.dp,
drawTick = { _, _ -> },
drawStopIndicator = null,
)
},
thumb = {
SliderDefaults.Thumb(
modifier =
Modifier
.height(18.dp)
.width(8.dp)
.padding(
vertical = 4.dp,
),
thumbSize = DpSize(8.dp, 8.dp),
interactionSource =
remember {
MutableInteractionSource()
},
colors =
SliderDefaults.colors().copy(
thumbColor = Color.White,
activeTrackColor = Color.White,
inactiveTrackColor = Color.Transparent,
),
enabled = true,
)
},
)
}
}
LazyColumn {
item {
// Time Layout
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp),
) {
IconButton(
Text(
text = if (timelineState.isLive) "" else formatDuration(timelineState.current),
style = typo().bodyMedium,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Left,
)
AnimatedVisibility(
enter = fadeIn(),
exit = fadeOut(),
visible = timelineState.isCrossfading,
) {
Text(
text = stringResource(Res.string.crossfading),
style = typo().bodyMedium,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
)
}
Text(
text = timelineState.lengthLabel(),
style = typo().bodyMedium,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Right,
)
}
Spacer(
modifier =
Modifier
.fillMaxWidth()
.height(5.dp),
)
}
item {
// Control Buttons - Animated visibility
AnimatedVisibility(
visible = showControlButtons,
enter =
expandVertically(
tween(300),
),
exit =
shrinkVertically(
tween(300),
),
) {
PlayerControlLayout(controllerState) {
sharedViewModel.onUIEvent(it)
}
}
AnimatedVisibility(
visible = showControlButtons,
enter =
expandVertically(
tween(300),
),
exit =
shrinkVertically(
tween(300),
),
) {
// List Bottom Buttons
Box(
modifier =
Modifier
.size(24.dp)
.aspectRatio(1f)
.align(Alignment.CenterStart)
.clip(
CircleShape,
),
onClick = {
showInfoBottomSheet = true
showControlButtons = true
},
.height(32.dp)
.fillMaxWidth()
.padding(horizontal = 40.dp),
) {
Icon(imageVector = SimpIcons.Info, tint = Color.White, contentDescription = "")
}
Row(
Modifier.align(Alignment.CenterEnd),
) {
Spacer(modifier = Modifier.size(8.dp))
IconButton(
modifier =
Modifier
.size(24.dp)
.aspectRatio(1f)
.align(Alignment.CenterStart)
.clip(
CircleShape,
),
onClick = {
showQueueBottomSheet = true
showInfoBottomSheet = true
showControlButtons = true
},
) {
Icon(
imageVector = SimpIcons.QueueMusic,
tint = Color.White,
contentDescription = "",
)
Icon(imageVector = SimpIcons.Info, tint = Color.White, contentDescription = "")
}
Row(
Modifier.align(Alignment.CenterEnd),
) {
Spacer(modifier = Modifier.size(8.dp))
IconButton(
modifier =
Modifier
.size(24.dp)
.aspectRatio(1f)
.clip(
CircleShape,
),
onClick = {
showQueueBottomSheet = true
showControlButtons = true
},
) {
Icon(
imageVector = SimpIcons.QueueMusic,
tint = Color.White,
contentDescription = "",
)
}
}
}
Spacer(modifier = Modifier.height(20.dp))
}
Spacer(modifier = Modifier.height(20.dp))
}
}
}

View file

@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.maxrave.simpmusic.extension.angledGradientBackground
@ -58,7 +59,9 @@ fun MoodCategoryCard(
// square. This one is 2:1, so the same fractions would double it — anchor on
// the height instead to keep the badge the size the eye expects.
val radius = size.height * 0.09f
val centerX = size.width - radius * 2f
// The corner opposite the title, which starts on the right in a right-to-left
// language; drawn at a fixed right corner, the badge sat on top of the title.
val centerX = if (layoutDirection == LayoutDirection.Rtl) radius * 2f else size.width - radius * 2f
val centerY = radius * 2f
drawCircle(
center = Offset(centerX, centerY),

View file

@ -22,6 +22,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.maxrave.domain.mediaservice.handler.ControlState
import com.maxrave.domain.mediaservice.handler.RepeatState
import com.maxrave.simpmusic.extension.LeftToRight
import com.maxrave.simpmusic.ui.icon.Pause
import com.maxrave.simpmusic.ui.icon.PauseCircle
import com.maxrave.simpmusic.ui.icon.PlayArrow
@ -51,7 +52,7 @@ fun PlayerControlLayout(
activeColor: Color = seed,
contentColor: Color = Color.White,
onUIEvent: (UIEvent) -> Unit,
) {
) = LeftToRight {
val height = if (isSmallSize) 48.dp else 96.dp
val smallIcon = if (isSmallSize) 20.dp to 28.dp else 32.dp to 42.dp
val mediumIcon = if (isSmallSize) 28.dp to 38.dp else 42.dp to 52.dp

View file

@ -4,8 +4,10 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
@ -37,7 +39,7 @@ val LocalNowPlayingOpen = compositionLocalOf { false }
*/
val LocalUserStarted = compositionLocalOf { false }
// Banners are portrait 720×1280 pictures; one of another shape is fitted, centred, inside this frame.
// Banners are portrait 720×1280 pictures. Only the width cap uses this, to keep one inside a short window.
private const val BANNER_ASPECT_RATIO = 9f / 16f
data class PromoBannerData(
@ -73,20 +75,26 @@ fun PromoBanner(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
// As wide as the window allows, but never taller than three quarters of it.
val width = min(maxWidth * 0.82f, maxHeight * 0.75f * BANNER_ASPECT_RATIO)
// At most 320dp wide upright and 360dp on its side; narrower only when the window is too short
// for a 9:16 picture to stay within 80% of its height (a phone held sideways, a small laptop).
val maxBannerWidth =
min(
if (maxWidth < maxHeight) 320.dp else 360.dp,
maxHeight * 0.8f * BANNER_ASPECT_RATIO,
)
val backdrop = rememberBackdrop(Color.Black)
Box(modifier = Modifier.size(width, width / BANNER_ASPECT_RATIO)) {
// The picture fills that width and its own shape sets the height.
Box(modifier = Modifier.widthIn(max = maxBannerWidth)) {
AsyncImage(
model = banner.imageUrl,
contentDescription = null,
placeholder = rememberHolderPainter(),
contentScale = ContentScale.Fit,
contentScale = ContentScale.FillWidth,
alignment = Alignment.Center,
onError = { onFailed() },
modifier =
Modifier
.matchParentSize()
.fillMaxWidth()
.clip(MaterialTheme.shapes.extraLarge)
.layerBackdrop(backdrop)
.clickable(onClick = onOpen),

View file

@ -22,7 +22,6 @@ val SimpIcons.ArrowForwardIos: ImageVector
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
autoMirror = true,
)
.apply {
path(

View file

@ -22,7 +22,6 @@ val SimpIcons.QueueMusic: ImageVector
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
autoMirror = true,
)
.apply {
path(

View file

@ -22,7 +22,6 @@ val SimpIcons.Sort: ImageVector
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
autoMirror = true,
)
.apply {
path(

View file

@ -22,7 +22,6 @@ val SimpIcons.TrendingUp: ImageVector
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
autoMirror = true,
)
.apply {
path(

View file

@ -22,7 +22,6 @@ val SimpIcons.VolumeOff: ImageVector
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
autoMirror = true,
)
.apply {
path(

View file

@ -22,7 +22,6 @@ val SimpIcons.VolumeUp: ImageVector
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
autoMirror = true,
)
.apply {
path(

View file

@ -114,6 +114,7 @@ import com.maxrave.logger.Logger
import com.maxrave.simpmusic.Platform
import com.maxrave.simpmusic.expect.toggleMiniPlayer
import com.maxrave.simpmusic.expect.ui.PlatformBackdrop
import com.maxrave.simpmusic.extension.LeftToRight
import com.maxrave.simpmusic.extension.elapsedLabel
import com.maxrave.simpmusic.extension.formatDuration
import com.maxrave.simpmusic.getPlatform
@ -569,21 +570,23 @@ fun MiniPlayer(
horizontal = 10.dp,
).align(Alignment.BottomCenter),
) {
LinearProgressIndicator(
progress = { animatedProgress },
modifier =
Modifier
.fillMaxWidth()
.height(1.dp)
.background(
color = Color.Transparent,
shape = RoundedCornerShape(4.dp),
),
color = textColor,
trackColor = Color.Transparent,
strokeCap = StrokeCap.Round,
drawStopIndicator = {},
)
LeftToRight {
LinearProgressIndicator(
progress = { animatedProgress },
modifier =
Modifier
.fillMaxWidth()
.height(1.dp)
.background(
color = Color.Transparent,
shape = RoundedCornerShape(4.dp),
),
color = textColor,
trackColor = Color.Transparent,
strokeCap = StrokeCap.Round,
drawStopIndicator = {},
)
}
}
}
}
@ -1095,71 +1098,73 @@ fun MiniPlayer(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
// A vertical Slider is a horizontal one rotated a quarter
// turn: graphicsLayer rotates the drawing AND the pointer
// input, so drag direction follows the visual.
Slider(
value = volumeValue,
onValueChangeFinished = {
isVolumeSliding = false
sharedViewModel.onUIEvent(
UIEvent.UpdateVolume(volumeValue.coerceIn(0f, 1f)),
)
},
onValueChange = {
isVolumeSliding = true
volumeValue = it
},
valueRange = 0f..1f,
modifier =
Modifier
.graphicsLayer {
// transformOrigin(0, 0) and place(-width, 0) below are
// a pair — they only work together. Dropping the origin
// and centring the rotation leaves the slider drawn
// outside its own node, where the popup's clip() eats
// it and nothing shows at all.
rotationZ = 270f
transformOrigin = TransformOrigin(0f, 0f)
}.layout { measurable, constraints ->
val placeable =
measurable.measure(
Constraints(
minWidth = constraints.minHeight,
maxWidth = constraints.maxHeight,
minHeight = constraints.minWidth,
maxHeight = constraints.maxWidth,
),
)
layout(placeable.height, placeable.width) {
placeable.place(-placeable.width, 0)
}
}.width(VOLUME_SLIDER_LENGTH),
track = { sliderState ->
SliderDefaults.Track(
modifier =
Modifier
.height(4.dp),
enabled = true,
sliderState = sliderState,
colors =
SliderDefaults.colors().copy(
thumbColor = textColor,
activeTrackColor = textColor,
inactiveTrackColor = textColor.copy(alpha = 0.3f),
),
thumbTrackGapSize = 0.dp,
drawTick = { _, _ -> },
drawStopIndicator = null,
)
},
thumb = {
// No thumb: it never sat visually centred on the rotated track, and the
// slider drags the same without one — the active/inactive split marks the level.
Spacer(Modifier.size(0.dp))
},
)
LeftToRight {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
// A vertical Slider is a horizontal one rotated a quarter
// turn: graphicsLayer rotates the drawing AND the pointer
// input, so drag direction follows the visual.
Slider(
value = volumeValue,
onValueChangeFinished = {
isVolumeSliding = false
sharedViewModel.onUIEvent(
UIEvent.UpdateVolume(volumeValue.coerceIn(0f, 1f)),
)
},
onValueChange = {
isVolumeSliding = true
volumeValue = it
},
valueRange = 0f..1f,
modifier =
Modifier
.graphicsLayer {
// transformOrigin(0, 0) and place(-width, 0) below are
// a pair — they only work together. Dropping the origin
// and centring the rotation leaves the slider drawn
// outside its own node, where the popup's clip() eats
// it and nothing shows at all.
rotationZ = 270f
transformOrigin = TransformOrigin(0f, 0f)
}.layout { measurable, constraints ->
val placeable =
measurable.measure(
Constraints(
minWidth = constraints.minHeight,
maxWidth = constraints.maxHeight,
minHeight = constraints.minWidth,
maxHeight = constraints.maxWidth,
),
)
layout(placeable.height, placeable.width) {
placeable.place(-placeable.width, 0)
}
}.width(VOLUME_SLIDER_LENGTH),
track = { sliderState ->
SliderDefaults.Track(
modifier =
Modifier
.height(4.dp),
enabled = true,
sliderState = sliderState,
colors =
SliderDefaults.colors().copy(
thumbColor = textColor,
activeTrackColor = textColor,
inactiveTrackColor = textColor.copy(alpha = 0.3f),
),
thumbTrackGapSize = 0.dp,
drawTick = { _, _ -> },
drawStopIndicator = null,
)
},
thumb = {
// No thumb: it never sat visually centred on the rotated track, and the
// slider drags the same without one — the active/inactive split marks the level.
Spacer(Modifier.size(0.dp))
},
)
}
}
}
Icon(
@ -1206,7 +1211,7 @@ private fun CapsuleProgress(
onValueChange: (Float) -> Unit,
onValueChangeFinished: () -> Unit,
modifier: Modifier = Modifier,
) {
) = LeftToRight {
Box(
modifier = modifier.height(16.dp),
contentAlignment = Alignment.Center,

View file

@ -41,6 +41,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.maxrave.simpmusic.extension.LeftToRight
import com.maxrave.simpmusic.ui.icon.Check
import com.maxrave.simpmusic.ui.icon.KeyboardArrowDown
import com.maxrave.simpmusic.ui.icon.SimpIcons
@ -231,49 +232,53 @@ fun EqualizerSection(viewModel: SettingsViewModel = koinViewModel()) {
// The dB scale sits beside the plot, not inside it: drawing text into the canvas would
// put it under the curve the user is dragging.
Row(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.width(44.dp).height(CURVE_HEIGHT).padding(vertical = 8.dp),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.End,
) {
Text(
text = "+${BAND_RANGE_DB.roundToInt()}dB",
style = typo().bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
LeftToRight {
Row(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.width(44.dp).height(CURVE_HEIGHT).padding(vertical = 8.dp),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.End,
) {
Text(
text = "+${BAND_RANGE_DB.roundToInt()}dB",
style = typo().bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "-${BAND_RANGE_DB.roundToInt()}dB",
style = typo().bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
EqualizerCurve(
bands = bands,
curveColor = curveColor,
gridColor = gridColor,
onBandsChange = { viewModel.setEqualizerBands(it) },
modifier =
Modifier
.weight(1f)
.height(CURVE_HEIGHT)
.padding(start = 8.dp, top = 8.dp, bottom = 8.dp),
)
Text(
text = "-${BAND_RANGE_DB.roundToInt()}dB",
style = typo().bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
EqualizerCurve(
bands = bands,
curveColor = curveColor,
gridColor = gridColor,
onBandsChange = { viewModel.setEqualizerBands(it) },
modifier =
Modifier
.weight(1f)
.height(CURVE_HEIGHT)
.padding(start = 8.dp, top = 8.dp, bottom = 8.dp),
)
}
}
// Aligned to the plot, not to the block: the 44dp gutter above holds the dB scale, so the
// labels have to start where the curve does or every frequency sits off its own gridline.
Row(
modifier = Modifier.fillMaxWidth().padding(start = 44.dp + 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
EQUALIZER_BAND_LABELS.forEach { label ->
Text(
text = label,
style = typo().bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
LeftToRight {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 44.dp + 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
EQUALIZER_BAND_LABELS.forEach { label ->
Text(
text = label,
style = typo().bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Bottom right, the way a dialog puts its action — it applies to the whole block above.

View file

@ -30,6 +30,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -43,9 +44,11 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import com.maxrave.simpmusic.extension.LeftToRight
import com.maxrave.simpmusic.ui.icon.ArrowForwardIos
import simpmusic.composeapp.generated.resources.analytics_avg_per_day
import simpmusic.composeapp.generated.resources.analytics_busiest_day
@ -867,19 +870,26 @@ private fun PeriodNavigator(
) {
val start = uiState.periodStart
val end = uiState.periodEnd
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = gutter),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (gutter == 0.dp) Arrangement.spacedBy(14.dp) else Arrangement.SpaceBetween,
) {
StepArrow(SimpIcons.ArrowBackIosNew, enabled = true) { onStep(-1) }
Text(
text = if (start != null && end != null) formatPeriodSpan(start, end) else "",
style = typo().bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
StepArrow(SimpIcons.ArrowForwardIos, enabled = uiState.canStepForward) { onStep(1) }
// The arrows follow the timeline, earlier on the left; the date between them keeps the
// reading direction, or a right-to-left date would come out in the wrong order.
val readingDirection = LocalLayoutDirection.current
LeftToRight {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = gutter),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (gutter == 0.dp) Arrangement.spacedBy(14.dp) else Arrangement.SpaceBetween,
) {
StepArrow(SimpIcons.ArrowBackIosNew, enabled = true) { onStep(-1) }
CompositionLocalProvider(LocalLayoutDirection provides readingDirection) {
Text(
text = if (start != null && end != null) formatPeriodSpan(start, end) else "",
style = typo().bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
)
}
StepArrow(SimpIcons.ArrowForwardIos, enabled = uiState.canStepForward) { onStep(1) }
}
}
}

View file

@ -29,6 +29,7 @@ import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -44,6 +45,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
@ -60,6 +62,7 @@ import com.maxrave.simpmusic.expect.ui.layerBackdrop
import com.maxrave.simpmusic.expect.ui.rememberBackdrop
import com.maxrave.simpmusic.expect.ui.rememberSaveImagePermission
import com.maxrave.simpmusic.expect.ui.toPngByteArray
import com.maxrave.simpmusic.extension.LeftToRight
import com.maxrave.simpmusic.getPlatform
import com.maxrave.simpmusic.ui.component.CenterLoadingBox
import com.maxrave.simpmusic.ui.component.LiquidGlassIconButton
@ -333,18 +336,25 @@ private fun WrappedReel(
.windowInsetsPadding(WindowInsets.systemBars)
.padding(top = WrappedTokens.HeaderHeight),
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
key = { cards[it].name },
) { index ->
WrappedCardSlot(
card = cards[index],
wrapped = wrapped,
captureController = captureController,
onSave = share.onSave,
onShare = share.onShare,
)
// Cards run left-to-right, matching the progress segments and the tap zones (the
// right half goes forward); each card keeps the reading direction for its text.
val readingDirection = LocalLayoutDirection.current
LeftToRight {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
key = { cards[it].name },
) { index ->
CompositionLocalProvider(LocalLayoutDirection provides readingDirection) {
WrappedCardSlot(
card = cards[index],
wrapped = wrapped,
captureController = captureController,
onSave = share.onSave,
onShare = share.onShare,
)
}
}
}
}
@ -462,34 +472,36 @@ private fun WrappedHeader(
.height(WrappedTokens.HeaderHeight),
) {
Spacer(modifier = Modifier.height(SEGMENT_TOP_INSET))
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.spacedBy(WrappedTokens.SegmentGap),
) {
repeat(cardCount) { index ->
// A real progress indicator rather than a hand-drawn rect, and the same one for all
// three states — a spent card is simply a full one. The fraction is read inside the
// indicator's own draw, so the sixty writes a second the timer makes never reach
// composition; the gap and the stop indicator are turned off because at 3dp they
// are the whole segment rather than a detail of it.
LinearProgressIndicator(
progress = {
when {
index < currentCard -> 1f
index == currentCard -> progress().coerceIn(0f, 1f)
else -> 0f
}
},
color = MaterialTheme.colorScheme.onSurface,
trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = SEGMENT_TRACK_ALPHA),
strokeCap = StrokeCap.Round,
gapSize = 0.dp,
drawStopIndicator = {},
modifier =
Modifier
.weight(1f)
.height(WrappedTokens.SegmentHeight),
)
LeftToRight {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp),
horizontalArrangement = Arrangement.spacedBy(WrappedTokens.SegmentGap),
) {
repeat(cardCount) { index ->
// A real progress indicator rather than a hand-drawn rect, and the same one for all
// three states — a spent card is simply a full one. The fraction is read inside the
// indicator's own draw, so the sixty writes a second the timer makes never reach
// composition; the gap and the stop indicator are turned off because at 3dp they
// are the whole segment rather than a detail of it.
LinearProgressIndicator(
progress = {
when {
index < currentCard -> 1f
index == currentCard -> progress().coerceIn(0f, 1f)
else -> 0f
}
},
color = MaterialTheme.colorScheme.onSurface,
trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = SEGMENT_TRACK_ALPHA),
strokeCap = StrokeCap.Round,
gapSize = 0.dp,
drawStopIndicator = {},
modifier =
Modifier
.weight(1f)
.height(WrappedTokens.SegmentHeight),
)
}
}
}
Spacer(modifier = Modifier.height(SEGMENT_BOTTOM_INSET))

View file

@ -63,6 +63,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.maxrave.common.Config.MAIN_PLAYER
import com.maxrave.simpmusic.expect.ui.MediaPlayerViewWithSubtitle
import com.maxrave.simpmusic.extension.LeftToRight
import com.maxrave.simpmusic.extension.artworkScrimBrush
import com.maxrave.simpmusic.extension.elapsedLabel
import com.maxrave.simpmusic.extension.formatDuration
@ -371,380 +372,384 @@ fun FullscreenPlayer(
}
},
)
Row(
Modifier
.align(Alignment.Center)
.fillMaxWidth(0.3f),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(48.dp)
.aspectRatio(1f)
.clip(
CircleShape,
),
enabled = controllerState.isPreviousAvailable,
onClick = {
sharedViewModel.onUIEvent(UIEvent.Previous)
},
) {
Icon(
imageVector = SimpIcons.SkipPrevious,
tint = if (controllerState.isPreviousAvailable) Color.White else Color.DarkGray,
contentDescription = "",
modifier =
Modifier
.size(36.dp),
)
}
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(48.dp)
.aspectRatio(1f)
.clip(
CircleShape,
),
onClick = {
sharedViewModel.onUIEvent(UIEvent.Backward)
},
) {
Icon(
imageVector = SimpIcons.Replay5,
tint = Color.White,
contentDescription = "",
modifier =
Modifier
.size(36.dp),
)
}
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(64.dp)
.aspectRatio(1f)
.clip(
CircleShape,
),
onClick = {
sharedViewModel.onUIEvent(UIEvent.PlayPause)
},
) {
Crossfade(controllerState.isPlaying) {
if (it) {
Icon(
imageVector = SimpIcons.Pause,
tint = Color.White,
contentDescription = "",
modifier =
Modifier
.size(48.dp),
)
} else {
Icon(
imageVector = SimpIcons.PlayArrow,
tint = Color.White,
contentDescription = "",
modifier =
Modifier
.size(48.dp),
)
}
}
}
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(48.dp)
.aspectRatio(1f)
.clip(
CircleShape,
),
onClick = {
sharedViewModel.onUIEvent(UIEvent.Forward)
},
) {
Icon(
imageVector = SimpIcons.Forward5,
tint = Color.White,
contentDescription = "",
modifier =
Modifier
.size(36.dp),
)
}
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(48.dp)
.aspectRatio(1f)
.clip(
CircleShape,
),
enabled = controllerState.isNextAvailable,
onClick = {
sharedViewModel.onUIEvent(UIEvent.Next)
},
) {
Icon(
imageVector = SimpIcons.SkipNext,
tint = if (controllerState.isNextAvailable) Color.White else Color.DarkGray,
contentDescription = "",
modifier =
Modifier
.size(36.dp),
)
}
}
Column(
modifier =
LeftToRight {
Row(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.fillMaxHeight(0.3f)
.padding(horizontal = 40.dp),
verticalArrangement = Arrangement.Bottom,
) {
Box(
Modifier
.padding(
vertical = 5.dp,
),
.align(Alignment.Center)
.fillMaxWidth(0.3f),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Box(
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.fillMaxWidth()
.height(24.dp),
contentAlignment = Alignment.Center,
.size(48.dp)
.aspectRatio(1f)
.clip(
CircleShape,
),
enabled = controllerState.isPreviousAvailable,
onClick = {
sharedViewModel.onUIEvent(UIEvent.Previous)
},
) {
Crossfade(timelineState.loading) {
Icon(
imageVector = SimpIcons.SkipPrevious,
tint = if (controllerState.isPreviousAvailable) Color.White else Color.DarkGray,
contentDescription = "",
modifier =
Modifier
.size(36.dp),
)
}
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(48.dp)
.aspectRatio(1f)
.clip(
CircleShape,
),
onClick = {
sharedViewModel.onUIEvent(UIEvent.Backward)
},
) {
Icon(
imageVector = SimpIcons.Replay5,
tint = Color.White,
contentDescription = "",
modifier =
Modifier
.size(36.dp),
)
}
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(64.dp)
.aspectRatio(1f)
.clip(
CircleShape,
),
onClick = {
sharedViewModel.onUIEvent(UIEvent.PlayPause)
},
) {
Crossfade(controllerState.isPlaying) {
if (it) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
LinearProgressIndicator(
modifier =
Modifier
.fillMaxWidth()
.height(4.dp)
.padding(
horizontal = 3.dp,
).clip(
RoundedCornerShape(8.dp),
),
color = Color.Gray,
trackColor = Color.DarkGray,
strokeCap = StrokeCap.Round,
)
}
Icon(
imageVector = SimpIcons.Pause,
tint = Color.White,
contentDescription = "",
modifier =
Modifier
.size(48.dp),
)
} else {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
LinearProgressIndicator(
progress = { timelineState.bufferedPercent.toFloat() / 100 },
modifier =
Modifier
.fillMaxWidth()
.height(4.dp)
.padding(
horizontal = 3.dp,
).clip(
RoundedCornerShape(8.dp),
),
color = Color.Gray,
trackColor = Color.DarkGray,
strokeCap = StrokeCap.Round,
drawStopIndicator = {},
)
}
Icon(
imageVector = SimpIcons.PlayArrow,
tint = Color.White,
contentDescription = "",
modifier =
Modifier
.size(48.dp),
)
}
}
}
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Slider(
// Fraction, not 0..100 — see the note in NowPlayingScreen:
// material3 alpha25 drops valueRange on its
// binary-compatibility overload.
value = sliderValue / 100f,
onValueChange = { value ->
isSliding = true
sliderValue = value * 100f
},
onValueChangeFinished = {
isSliding = false
sharedViewModel.onUIEvent(
UIEvent.UpdateProgress(sliderValue),
)
},
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(48.dp)
.aspectRatio(1f)
.clip(
CircleShape,
),
onClick = {
sharedViewModel.onUIEvent(UIEvent.Forward)
},
) {
Icon(
imageVector = SimpIcons.Forward5,
tint = Color.White,
contentDescription = "",
modifier =
Modifier
.size(36.dp),
)
}
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(48.dp)
.aspectRatio(1f)
.clip(
CircleShape,
),
enabled = controllerState.isNextAvailable,
onClick = {
sharedViewModel.onUIEvent(UIEvent.Next)
},
) {
Icon(
imageVector = SimpIcons.SkipNext,
tint = if (controllerState.isNextAvailable) Color.White else Color.DarkGray,
contentDescription = "",
modifier =
Modifier
.size(36.dp),
)
}
}
}
LeftToRight {
Column(
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.fillMaxHeight(0.3f)
.padding(horizontal = 40.dp),
verticalArrangement = Arrangement.Bottom,
) {
Box(
Modifier
.padding(
vertical = 5.dp,
),
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(top = 3.dp)
.align(
Alignment.TopCenter,
),
track = { sliderState ->
SliderDefaults.Track(
modifier =
Modifier
.height(5.dp),
enabled = true,
sliderState = sliderState,
colors =
SliderDefaults.colors().copy(
thumbColor = Color.White,
activeTrackColor = Color.White,
inactiveTrackColor = Color.Transparent,
),
thumbTrackGapSize = 0.dp,
drawTick = { _, _ -> },
drawStopIndicator = null,
)
},
thumb = {
SliderDefaults.Thumb(
modifier =
Modifier
.height(18.dp)
.width(8.dp)
.padding(
vertical = 4.dp,
),
thumbSize = DpSize(8.dp, 8.dp),
interactionSource =
remember {
MutableInteractionSource()
},
colors =
SliderDefaults.colors().copy(
thumbColor = Color.White,
activeTrackColor = Color.White,
inactiveTrackColor = Color.Transparent,
),
enabled = true,
)
},
)
}
}
// /
Box(
modifier =
Modifier
.height(48.dp)
.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.wrapContentSize()
.align(Alignment.TopStart),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = timelineState.elapsedLabel(sliderValue / 100f),
style = typo().labelSmall,
)
Spacer(Modifier.width(4.dp))
Text(
// LIVE stands on its own: there is no elapsed time in front of it to divide from.
text = if (timelineState.isLive) timelineState.lengthLabel() else " / ${timelineState.lengthLabel()}",
style = typo().bodySmall,
)
}
Row(
Modifier
.fillMaxHeight()
.wrapContentWidth()
.align(Alignment.CenterEnd),
verticalAlignment = Alignment.Top,
) {
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(32.dp)
.aspectRatio(1f)
.clip(
CircleShape,
),
onClick = {
shouldShowSubtitle = !shouldShowSubtitle
},
.height(24.dp),
contentAlignment = Alignment.Center,
) {
Crossfade(shouldShowSubtitle) {
Crossfade(timelineState.loading) {
if (it) {
Icon(
imageVector = SimpIcons.SubtitlesOff,
tint = Color.White,
contentDescription = "",
modifier =
Modifier
.size(24.dp),
)
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
LinearProgressIndicator(
modifier =
Modifier
.fillMaxWidth()
.height(4.dp)
.padding(
horizontal = 3.dp,
).clip(
RoundedCornerShape(8.dp),
),
color = Color.Gray,
trackColor = Color.DarkGray,
strokeCap = StrokeCap.Round,
)
}
} else {
Icon(
imageVector = SimpIcons.Subtitles,
tint = Color.White,
contentDescription = "",
modifier =
Modifier
.size(24.dp),
)
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
LinearProgressIndicator(
progress = { timelineState.bufferedPercent.toFloat() / 100 },
modifier =
Modifier
.fillMaxWidth()
.height(4.dp)
.padding(
horizontal = 3.dp,
).clip(
RoundedCornerShape(8.dp),
),
color = Color.Gray,
trackColor = Color.DarkGray,
strokeCap = StrokeCap.Round,
drawStopIndicator = {},
)
}
}
}
}
Spacer(Modifier.width(8.dp))
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(32.dp)
.aspectRatio(1f)
.clip(
CircleShape,
),
onClick = {
navController.navigateUp()
},
) {
Icon(
imageVector = SimpIcons.FullscreenExit,
tint = Color.White,
contentDescription = "",
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Slider(
// Fraction, not 0..100 — see the note in NowPlayingScreen:
// material3 alpha25 drops valueRange on its
// binary-compatibility overload.
value = sliderValue / 100f,
onValueChange = { value ->
isSliding = true
sliderValue = value * 100f
},
onValueChangeFinished = {
isSliding = false
sharedViewModel.onUIEvent(
UIEvent.UpdateProgress(sliderValue),
)
},
modifier =
Modifier
.size(24.dp),
.fillMaxWidth()
.padding(top = 3.dp)
.align(
Alignment.TopCenter,
),
track = { sliderState ->
SliderDefaults.Track(
modifier =
Modifier
.height(5.dp),
enabled = true,
sliderState = sliderState,
colors =
SliderDefaults.colors().copy(
thumbColor = Color.White,
activeTrackColor = Color.White,
inactiveTrackColor = Color.Transparent,
),
thumbTrackGapSize = 0.dp,
drawTick = { _, _ -> },
drawStopIndicator = null,
)
},
thumb = {
SliderDefaults.Thumb(
modifier =
Modifier
.height(18.dp)
.width(8.dp)
.padding(
vertical = 4.dp,
),
thumbSize = DpSize(8.dp, 8.dp),
interactionSource =
remember {
MutableInteractionSource()
},
colors =
SliderDefaults.colors().copy(
thumbColor = Color.White,
activeTrackColor = Color.White,
inactiveTrackColor = Color.Transparent,
),
enabled = true,
)
},
)
}
}
// /
Box(
modifier =
Modifier
.height(48.dp)
.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.wrapContentSize()
.align(Alignment.TopStart),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = timelineState.elapsedLabel(sliderValue / 100f),
style = typo().labelSmall,
)
Spacer(Modifier.width(4.dp))
Text(
// LIVE stands on its own: there is no elapsed time in front of it to divide from.
text = if (timelineState.isLive) timelineState.lengthLabel() else " / ${timelineState.lengthLabel()}",
style = typo().bodySmall,
)
}
Row(
Modifier
.fillMaxHeight()
.wrapContentWidth()
.align(Alignment.CenterEnd),
verticalAlignment = Alignment.Top,
) {
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(32.dp)
.aspectRatio(1f)
.clip(
CircleShape,
),
onClick = {
shouldShowSubtitle = !shouldShowSubtitle
},
) {
Crossfade(shouldShowSubtitle) {
if (it) {
Icon(
imageVector = SimpIcons.SubtitlesOff,
tint = Color.White,
contentDescription = "",
modifier =
Modifier
.size(24.dp),
)
} else {
Icon(
imageVector = SimpIcons.Subtitles,
tint = Color.White,
contentDescription = "",
modifier =
Modifier
.size(24.dp),
)
}
}
}
Spacer(Modifier.width(8.dp))
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(32.dp)
.aspectRatio(1f)
.clip(
CircleShape,
),
onClick = {
navController.navigateUp()
},
) {
Icon(
imageVector = SimpIcons.FullscreenExit,
tint = Color.White,
contentDescription = "",
modifier =
Modifier
.size(24.dp),
)
}
}
}
}
}
}

View file

@ -82,6 +82,7 @@ import com.maxrave.simpmusic.expect.ui.layerBackdrop
import com.maxrave.simpmusic.expect.ui.rememberBackdrop
import com.maxrave.simpmusic.expect.ui.rememberDeviceVolumeController
import com.maxrave.simpmusic.expect.ui.toImageBitmap
import com.maxrave.simpmusic.extension.LeftToRight
import com.maxrave.simpmusic.extension.smoothScrimBrush
import com.maxrave.simpmusic.getPlatform
import com.maxrave.simpmusic.ui.component.LyricText
@ -430,39 +431,41 @@ private fun AppleMusicMainView(
val topBand =
if (meshPage && getPlatform() == Platform.Android) statusBarHeight + TOP_BAND_STRIP else 0.dp
HorizontalPager(
state = state.artworkPagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1,
userScrollEnabled = !isRepeatOne && state.artworkQueue.isNotEmpty(),
key = { idx ->
val vid = state.artworkQueue.getOrNull(idx)?.videoId.orEmpty()
"appleMusicArtwork_${vid}_$idx"
},
) { page ->
AppleMusicArtworkPage(
state = state,
actions = actions,
localDensity = localDensity,
page = page,
artworkHeight = artworkHeight,
clipMaxHeight = clipMaxHeight,
topBandSolid = statusBarHeight,
topBand = topBand,
bottomContentHeightDp = bottomContentHeightDp,
fullscreenCanvas = fullscreenCanvas,
animatedArtwork = animatedArtwork,
isVideoBackdrop = isVideoBackdrop,
showVideoOverlay = showVideoOverlay,
onToggleVideoOverlay = { showVideoOverlay = !showVideoOverlay },
showSubtitle = showSubtitle,
onToggleSubtitle = { showSubtitle = !showSubtitle },
onCoverBitmap = { coverBitmap = it },
onClipStill = { still, height ->
clipStill = still
clipFrameHeight = height
LeftToRight {
HorizontalPager(
state = state.artworkPagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1,
userScrollEnabled = !isRepeatOne && state.artworkQueue.isNotEmpty(),
key = { idx ->
val vid = state.artworkQueue.getOrNull(idx)?.videoId.orEmpty()
"appleMusicArtwork_${vid}_$idx"
},
)
) { page ->
AppleMusicArtworkPage(
state = state,
actions = actions,
localDensity = localDensity,
page = page,
artworkHeight = artworkHeight,
clipMaxHeight = clipMaxHeight,
topBandSolid = statusBarHeight,
topBand = topBand,
bottomContentHeightDp = bottomContentHeightDp,
fullscreenCanvas = fullscreenCanvas,
animatedArtwork = animatedArtwork,
isVideoBackdrop = isVideoBackdrop,
showVideoOverlay = showVideoOverlay,
onToggleVideoOverlay = { showVideoOverlay = !showVideoOverlay },
showSubtitle = showSubtitle,
onToggleSubtitle = { showSubtitle = !showSubtitle },
onCoverBitmap = { coverBitmap = it },
onClipStill = { still, height ->
clipStill = still
clipFrameHeight = height
},
)
}
}
// Android plays the animated artwork on a SurfaceView, which no Compose mask or alpha reaches:
@ -1058,31 +1061,33 @@ private fun AppleMusicArtworkPage(
) {
Icon(imageVector = SimpIcons.Fullscreen, contentDescription = "", tint = Color.White)
}
Row(
modifier = Modifier.align(Alignment.Center).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
IconButton(
onClick = { actions.onUIEvent(UIEvent.Backward) },
modifier = Modifier.size(48.dp).clip(CircleShape),
LeftToRight {
Row(
modifier = Modifier.align(Alignment.Center).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Icon(
imageVector = SimpIcons.Replay5,
contentDescription = "",
tint = Color.White,
modifier = Modifier.size(36.dp).alpha(0.8f),
)
}
IconButton(
onClick = { actions.onUIEvent(UIEvent.Forward) },
modifier = Modifier.size(48.dp).clip(CircleShape),
) {
Icon(
imageVector = SimpIcons.Forward5,
contentDescription = "",
tint = Color.White,
modifier = Modifier.size(36.dp).alpha(0.8f),
)
IconButton(
onClick = { actions.onUIEvent(UIEvent.Backward) },
modifier = Modifier.size(48.dp).clip(CircleShape),
) {
Icon(
imageVector = SimpIcons.Replay5,
contentDescription = "",
tint = Color.White,
modifier = Modifier.size(36.dp).alpha(0.8f),
)
}
IconButton(
onClick = { actions.onUIEvent(UIEvent.Forward) },
modifier = Modifier.size(48.dp).clip(CircleShape),
) {
Icon(
imageVector = SimpIcons.Forward5,
contentDescription = "",
tint = Color.White,
modifier = Modifier.size(36.dp).alpha(0.8f),
)
}
}
}
if (state.screenData.lyricsData != null) {

View file

@ -87,6 +87,7 @@ import com.materialkolor.rememberDynamicColorScheme
import com.maxrave.domain.mediaservice.handler.RepeatState
import com.maxrave.simpmusic.Platform
import com.maxrave.simpmusic.expect.ui.toReadableBitmap
import com.maxrave.simpmusic.extension.LeftToRight
import com.maxrave.simpmusic.extension.elapsedLabel
import com.maxrave.simpmusic.extension.formatDuration
import com.maxrave.simpmusic.extension.lengthLabel
@ -301,30 +302,32 @@ private fun NowPlayingM3ExpressiveLayout(
) {
Box(modifier = Modifier.fillMaxWidth()) {
// === 4. Artwork pager — same state/sync as Classic, card presentation ===
HorizontalPager(
state = state.artworkPagerState,
modifier =
Modifier
.height(screenInfo.hDP.dp)
.fillMaxWidth(),
beyondViewportPageCount = 1,
userScrollEnabled = !isRepeatOne && state.artworkQueue.isNotEmpty(),
key = { idx ->
val vid = state.artworkQueue.getOrNull(idx)?.videoId.orEmpty()
"artwork_${vid}_$idx"
},
) { page ->
ExpressiveArtworkCardPage(
state = state,
actions = actions,
page = page,
topAppBarHeightDp = topAppBarHeightDp,
middleLayoutPaddingDp = middleLayoutPaddingDp,
showHideFullscreenOverlay = showHideFullscreenOverlay,
onToggleFullscreenOverlay = {
showHideFullscreenOverlay = !showHideFullscreenOverlay
LeftToRight {
HorizontalPager(
state = state.artworkPagerState,
modifier =
Modifier
.height(screenInfo.hDP.dp)
.fillMaxWidth(),
beyondViewportPageCount = 1,
userScrollEnabled = !isRepeatOne && state.artworkQueue.isNotEmpty(),
key = { idx ->
val vid = state.artworkQueue.getOrNull(idx)?.videoId.orEmpty()
"artwork_${vid}_$idx"
},
)
) { page ->
ExpressiveArtworkCardPage(
state = state,
actions = actions,
page = page,
topAppBarHeightDp = topAppBarHeightDp,
middleLayoutPaddingDp = middleLayoutPaddingDp,
showHideFullscreenOverlay = showHideFullscreenOverlay,
onToggleFullscreenOverlay = {
showHideFullscreenOverlay = !showHideFullscreenOverlay
},
)
}
}
// === 3. Header row ===
@ -826,7 +829,7 @@ internal fun ColumnScope.ExpressivePlaybackControls(
actions: NowPlayingContentActions,
sliderModifier: Modifier = Modifier,
showShuffleAndRepeat: Boolean = false,
) {
) = LeftToRight {
val colorScheme = MaterialTheme.colorScheme
Box(
Modifier
@ -950,7 +953,7 @@ private fun ExpressiveConnectedGroup(
state: NowPlayingContentState,
actions: NowPlayingContentActions,
onOpenOutput: () -> Unit,
) {
) = LeftToRight {
val colorScheme = MaterialTheme.colorScheme
val startCap = RoundedCornerShape(topStart = 24.dp, topEnd = 6.dp, bottomEnd = 6.dp, bottomStart = 24.dp)
val endCap = RoundedCornerShape(topStart = 6.dp, topEnd = 24.dp, bottomEnd = 24.dp, bottomStart = 6.dp)

View file

@ -81,6 +81,7 @@ import com.maxrave.simpmusic.Platform
import com.maxrave.simpmusic.expect.ui.MediaPlayerView
import com.maxrave.simpmusic.expect.ui.MediaPlayerViewWithSubtitle
import com.maxrave.simpmusic.expect.ui.toImageBitmap
import com.maxrave.simpmusic.extension.LeftToRight
import com.maxrave.simpmusic.extension.parseTimestampToMilliseconds
import com.maxrave.simpmusic.extension.smoothScrimBrush
import com.maxrave.simpmusic.getPlatform
@ -431,59 +432,61 @@ internal fun ExpressiveArtworkCardPage(
tint = Color.White,
)
}
Row(
Modifier
.align(Alignment.Center)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(48.dp)
.aspectRatio(1f)
.clip(CircleShape),
onClick = {
actions.onUIEvent(UIEvent.Backward)
},
LeftToRight {
Row(
Modifier
.align(Alignment.Center)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Icon(
imageVector = SimpIcons.Replay5,
tint = Color.White,
contentDescription = "",
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(36.dp)
.alpha(0.8f),
)
}
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(48.dp)
.aspectRatio(1f)
.clip(CircleShape),
onClick = {
actions.onUIEvent(UIEvent.Forward)
},
) {
Icon(
imageVector = SimpIcons.Forward5,
tint = Color.White,
contentDescription = "",
.size(48.dp)
.aspectRatio(1f)
.clip(CircleShape),
onClick = {
actions.onUIEvent(UIEvent.Backward)
},
) {
Icon(
imageVector = SimpIcons.Replay5,
tint = Color.White,
contentDescription = "",
modifier =
Modifier
.size(36.dp)
.alpha(0.8f),
)
}
FilledTonalIconButton(
colors =
IconButtonDefaults.iconButtonColors().copy(
containerColor = Color.Transparent,
),
modifier =
Modifier
.size(36.dp)
.alpha(0.8f),
)
.size(48.dp)
.aspectRatio(1f)
.clip(CircleShape),
onClick = {
actions.onUIEvent(UIEvent.Forward)
},
) {
Icon(
imageVector = SimpIcons.Forward5,
tint = Color.White,
contentDescription = "",
modifier =
Modifier
.size(36.dp)
.alpha(0.8f),
)
}
}
}
if (state.screenData.lyricsData != null) {
@ -1041,21 +1044,23 @@ internal fun ExpressiveCollapsedToolbar(
.wrapContentSize(Alignment.Center)
.align(Alignment.BottomCenter),
) {
LinearProgressIndicator(
progress = { state.timelineState.current.toFloat() / state.timelineState.total },
modifier =
Modifier
.fillMaxWidth()
.height(1.dp)
.background(
color = Color.Transparent,
shape = RoundedCornerShape(4.dp),
),
color = colorScheme.primary,
trackColor = colorScheme.surfaceContainerHighest,
strokeCap = StrokeCap.Round,
drawStopIndicator = {},
)
LeftToRight {
LinearProgressIndicator(
progress = { state.timelineState.current.toFloat() / state.timelineState.total },
modifier =
Modifier
.fillMaxWidth()
.height(1.dp)
.background(
color = Color.Transparent,
shape = RoundedCornerShape(4.dp),
),
color = colorScheme.primary,
trackColor = colorScheme.surfaceContainerHighest,
strokeCap = StrokeCap.Round,
drawStopIndicator = {},
)
}
}
}
}

View file

@ -80,6 +80,7 @@ import com.maxrave.domain.mediaservice.handler.RepeatState
import com.maxrave.domain.repository.ListenTogetherRepository
import com.maxrave.simpmusic.Platform
import com.maxrave.simpmusic.expect.ui.DeviceVolumeController
import com.maxrave.simpmusic.extension.LeftToRight
import com.maxrave.simpmusic.extension.formatDuration
import com.maxrave.simpmusic.getPlatform
import com.maxrave.simpmusic.ui.component.ExplicitBadge
@ -750,7 +751,7 @@ internal fun AppleMusicTransportRow(
internal fun AppleMusicVolumeRow(
controller: DeviceVolumeController,
modifier: Modifier = Modifier,
) {
) = LeftToRight {
Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = SimpIcons.VolumeDown,
@ -828,7 +829,7 @@ internal fun AppleMusicActionRow(
onOpenOutput: () -> Unit,
onOpenListenTogether: () -> Unit,
modifier: Modifier = Modifier,
) {
) = LeftToRight {
// Straight off the repository, like ListenTogetherIconButton: one boolean off a StateFlow is all
// the capsule needs, and a ViewModel for it would be built every time the player opens.
val listenTogether = koinInject<ListenTogetherRepository>()
@ -959,7 +960,7 @@ internal fun ColumnScope.AppleMusicPlaybackControls(
actions: NowPlayingContentActions,
typography: AppleMusicTypography,
showShuffleAndRepeat: Boolean = false,
) {
) = LeftToRight {
// Fixed 18dp shell: the track swells on touch, but inside a CONSTANT footprint —
// otherwise the growing slider re-measures this whole column and the artwork above
// it visibly jumps. It also gives the bar a real 18dp touch target instead of 8.4dp.

View file

@ -6,10 +6,14 @@ import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.Font
import simpmusic.composeapp.generated.resources.Res
@ -40,6 +44,16 @@ fun typo(
val titleColor = if (forceDark) Color.White else colorScheme.onBackground
val bodyColor = if (forceDark) Color(0xFFA8A8A8) else colorScheme.onSurfaceVariant
// Right-to-left layouts only. Compose gives every paragraph the layout's direction unless told
// otherwise (TextStyle.resolveTextDirection), so a Latin title or URL was laid out right-to-left:
// its closing punctuation jumped to the front, and titles mixing Latin with Hebrew came out
// scrambled. Content lets each paragraph take its direction from its own text. Content alone
// would also move Latin text to the left edge, so Right keeps it on the layout's start edge,
// where a TextView keeps it with gravity START.
val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val textDirection = if (rtl) TextDirection.Content else TextDirection.Unspecified
val textAlign = if (rtl) TextAlign.Right else TextAlign.Unspecified
val typo =
Typography(
/***
@ -51,6 +65,8 @@ fun typo(
fontWeight = FontWeight.SemiBold,
fontFamily = fontFamily,
color = titleColor,
textDirection = textDirection,
textAlign = textAlign,
),
titleMedium =
TextStyle(
@ -58,6 +74,8 @@ fun typo(
fontWeight = FontWeight.SemiBold,
fontFamily = fontFamily,
color = titleColor,
textDirection = textDirection,
textAlign = textAlign,
),
titleLarge =
TextStyle(
@ -65,6 +83,8 @@ fun typo(
fontWeight = FontWeight.Bold,
fontFamily = fontFamily,
color = titleColor,
textDirection = textDirection,
textAlign = textAlign,
),
bodySmall =
TextStyle(
@ -72,6 +92,8 @@ fun typo(
fontWeight = FontWeight.Normal,
fontFamily = fontFamily,
color = bodyColor,
textDirection = textDirection,
textAlign = textAlign,
),
bodyMedium =
TextStyle(
@ -79,6 +101,8 @@ fun typo(
fontWeight = FontWeight.Normal,
fontFamily = fontFamily,
color = bodyColor,
textDirection = textDirection,
textAlign = textAlign,
),
bodyLarge =
TextStyle(
@ -86,6 +110,8 @@ fun typo(
fontWeight = FontWeight.Normal,
fontFamily = fontFamily,
color = bodyColor,
textDirection = textDirection,
textAlign = textAlign,
),
displayLarge =
TextStyle(
@ -93,6 +119,8 @@ fun typo(
fontWeight = FontWeight.Normal,
fontFamily = fontFamily,
color = bodyColor,
textDirection = textDirection,
textAlign = textAlign,
),
headlineMedium =
TextStyle(
@ -100,6 +128,8 @@ fun typo(
fontWeight = FontWeight.Bold,
fontFamily = fontFamily,
color = bodyColor,
textDirection = textDirection,
textAlign = textAlign,
),
headlineLarge =
TextStyle(
@ -107,6 +137,8 @@ fun typo(
fontWeight = FontWeight.Bold,
fontFamily = fontFamily,
color = bodyColor,
textDirection = textDirection,
textAlign = textAlign,
),
labelMedium =
TextStyle(
@ -114,6 +146,8 @@ fun typo(
fontWeight = FontWeight.Bold,
fontFamily = fontFamily,
color = bodyColor,
textDirection = textDirection,
textAlign = textAlign,
),
labelSmall =
TextStyle(
@ -121,6 +155,8 @@ fun typo(
fontWeight = FontWeight.SemiBold,
fontFamily = fontFamily,
color = bodyColor,
textDirection = textDirection,
textAlign = textAlign,
),
// ...
)

View file

@ -62,6 +62,7 @@ import coil3.compose.AsyncImage
import com.maxrave.domain.data.model.streams.TimeLine
import com.maxrave.domain.manager.DataStoreManager
import com.maxrave.domain.mediaservice.handler.ControlState
import com.maxrave.simpmusic.extension.LeftToRight
import com.maxrave.simpmusic.ui.component.rememberHolderPainter
import com.maxrave.simpmusic.extension.parseRichSyncWords
import com.maxrave.simpmusic.ui.component.PlayPauseButton
@ -96,68 +97,70 @@ private fun MiniPlayerSeekBar(
(timeline.current.toFloat() / timeline.total.toFloat())
.coerceIn(0f, 1f)
BoxWithConstraints(
modifier =
modifier
.fillMaxWidth()
.height(hitHeight)
.pointerInput(Unit) {
detectTapGestures { offset ->
val percent =
(offset.x / size.width)
.coerceIn(0f, 1f) * 100f
onUIEvent(UIEvent.UpdateProgress(percent))
}
}.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
LeftToRight {
BoxWithConstraints(
modifier =
modifier
.fillMaxWidth()
.height(hitHeight)
.pointerInput(Unit) {
detectTapGestures { offset ->
val percent =
(offset.x / size.width)
.coerceIn(0f, 1f) * 100f
onUIEvent(UIEvent.UpdateProgress(percent))
},
onDrag = { change, _ ->
val percent =
(change.position.x / size.width)
.coerceIn(0f, 1f) * 100f
onUIEvent(UIEvent.UpdateProgress(percent))
},
)
},
contentAlignment = Alignment.CenterStart,
) {
// Track
Box(
Modifier
.fillMaxWidth()
.height(trackHeight)
.align(Alignment.Center)
.background(
Color.White.copy(alpha = 0.25f),
RoundedCornerShape(50),
),
)
}
}.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
val percent =
(offset.x / size.width)
.coerceIn(0f, 1f) * 100f
onUIEvent(UIEvent.UpdateProgress(percent))
},
onDrag = { change, _ ->
val percent =
(change.position.x / size.width)
.coerceIn(0f, 1f) * 100f
onUIEvent(UIEvent.UpdateProgress(percent))
},
)
},
contentAlignment = Alignment.CenterStart,
) {
// Track
Box(
Modifier
.fillMaxWidth()
.height(trackHeight)
.align(Alignment.Center)
.background(
Color.White.copy(alpha = 0.25f),
RoundedCornerShape(50),
),
)
// Progress
Box(
Modifier
.width(maxWidth * progress)
.height(trackHeight)
.align(Alignment.CenterStart)
.background(
Color.White,
RoundedCornerShape(50),
),
)
// Progress
Box(
Modifier
.width(maxWidth * progress)
.height(trackHeight)
.align(Alignment.CenterStart)
.background(
Color.White,
RoundedCornerShape(50),
),
)
// Thumb
Box(
Modifier
.offset(x = (maxWidth * progress) - (thumbSize / 2))
.size(thumbSize)
.align(Alignment.CenterStart)
.background(Color.White, CircleShape),
)
// Thumb
Box(
Modifier
.offset(x = (maxWidth * progress) - (thumbSize / 2))
.size(thumbSize)
.align(Alignment.CenterStart)
.background(Color.White, CircleShape),
)
}
}
}
@ -196,37 +199,39 @@ fun CompactMiniLayout(
.alpha(alpha),
contentAlignment = Alignment.Center,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RippleIconButton(
imageVector = SimpIcons.SkipPrevious,
modifier = Modifier.size(28.dp),
tint = if (controllerState.isPreviousAvailable) Color.White else Color.Gray,
onClick = {
if (controllerState.isPreviousAvailable) {
onUIEvent(UIEvent.Previous)
}
},
)
LeftToRight {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RippleIconButton(
imageVector = SimpIcons.SkipPrevious,
modifier = Modifier.size(28.dp),
tint = if (controllerState.isPreviousAvailable) Color.White else Color.Gray,
onClick = {
if (controllerState.isPreviousAvailable) {
onUIEvent(UIEvent.Previous)
}
},
)
PlayPauseButton(
isPlaying = controllerState.isPlaying,
modifier = Modifier.size(36.dp),
onClick = { onUIEvent(UIEvent.PlayPause) },
)
PlayPauseButton(
isPlaying = controllerState.isPlaying,
modifier = Modifier.size(36.dp),
onClick = { onUIEvent(UIEvent.PlayPause) },
)
RippleIconButton(
imageVector = SimpIcons.SkipNext,
modifier = Modifier.size(28.dp),
tint = if (controllerState.isNextAvailable) Color.White else Color.Gray,
onClick = {
if (controllerState.isNextAvailable) {
onUIEvent(UIEvent.Next)
}
},
)
RippleIconButton(
imageVector = SimpIcons.SkipNext,
modifier = Modifier.size(28.dp),
tint = if (controllerState.isNextAvailable) Color.White else Color.Gray,
onClick = {
if (controllerState.isNextAvailable) {
onUIEvent(UIEvent.Next)
}
},
)
}
}
}
@ -300,93 +305,95 @@ fun MediumMiniLayout(
Spacer(modifier = Modifier.weight(1f))
// Controls - show extra buttons only if width >= 300dp
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Like button - only show if width >= 300dp
AnimatedVisibility(
visible = showExtraButtons,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut(),
LeftToRight {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = { onUIEvent(UIEvent.ToggleLike) },
modifier = Modifier.size(28.dp),
// Like button - only show if width >= 300dp
AnimatedVisibility(
visible = showExtraButtons,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut(),
) {
Icon(
imageVector =
if (controllerState.isLiked) {
SimpIcons.Favorite
} else {
SimpIcons.FavoriteBorder
},
contentDescription = "Like",
tint =
if (controllerState.isLiked) {
Color(0xFFFF4081)
} else {
Color.White.copy(alpha = 0.7f)
},
modifier = Modifier.size(18.dp),
)
IconButton(
onClick = { onUIEvent(UIEvent.ToggleLike) },
modifier = Modifier.size(28.dp),
) {
Icon(
imageVector =
if (controllerState.isLiked) {
SimpIcons.Favorite
} else {
SimpIcons.FavoriteBorder
},
contentDescription = "Like",
tint =
if (controllerState.isLiked) {
Color(0xFFFF4081)
} else {
Color.White.copy(alpha = 0.7f)
},
modifier = Modifier.size(18.dp),
)
}
}
}
RippleIconButton(
imageVector = SimpIcons.SkipPrevious,
modifier = Modifier.size(28.dp),
tint = if (controllerState.isPreviousAvailable) Color.White else Color.Gray,
onClick = {
if (controllerState.isPreviousAvailable) {
onUIEvent(UIEvent.Previous)
}
},
)
PlayPauseButton(
isPlaying = controllerState.isPlaying,
modifier = Modifier.size(36.dp),
onClick = { onUIEvent(UIEvent.PlayPause) },
)
RippleIconButton(
imageVector = SimpIcons.SkipNext,
modifier = Modifier.size(28.dp),
tint = if (controllerState.isNextAvailable) Color.White else Color.Gray,
onClick = {
if (controllerState.isNextAvailable) {
onUIEvent(UIEvent.Next)
}
},
)
// Volume button - only show if width >= 300dp
AnimatedVisibility(
visible = showExtraButtons,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut(),
) {
val previousVolume = rememberPreviousVolume(controllerState.volume)
IconButton(
onClick = {
// Toggle mute/unmute
val newVolume = if (controllerState.volume > 0f) 0f else previousVolume.coerceIn(0.1f, 1f)
onUIEvent(UIEvent.UpdateVolume(newVolume))
},
RippleIconButton(
imageVector = SimpIcons.SkipPrevious,
modifier = Modifier.size(28.dp),
tint = if (controllerState.isPreviousAvailable) Color.White else Color.Gray,
onClick = {
if (controllerState.isPreviousAvailable) {
onUIEvent(UIEvent.Previous)
}
},
)
PlayPauseButton(
isPlaying = controllerState.isPlaying,
modifier = Modifier.size(36.dp),
onClick = { onUIEvent(UIEvent.PlayPause) },
)
RippleIconButton(
imageVector = SimpIcons.SkipNext,
modifier = Modifier.size(28.dp),
tint = if (controllerState.isNextAvailable) Color.White else Color.Gray,
onClick = {
if (controllerState.isNextAvailable) {
onUIEvent(UIEvent.Next)
}
},
)
// Volume button - only show if width >= 300dp
AnimatedVisibility(
visible = showExtraButtons,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut(),
) {
Icon(
imageVector =
if (controllerState.volume > 0f) {
SimpIcons.VolumeUp
} else {
SimpIcons.VolumeOff
},
contentDescription = if (controllerState.volume > 0f) "Mute" else "Unmute",
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp),
)
val previousVolume = rememberPreviousVolume(controllerState.volume)
IconButton(
onClick = {
// Toggle mute/unmute
val newVolume = if (controllerState.volume > 0f) 0f else previousVolume.coerceIn(0.1f, 1f)
onUIEvent(UIEvent.UpdateVolume(newVolume))
},
modifier = Modifier.size(28.dp),
) {
Icon(
imageVector =
if (controllerState.volume > 0f) {
SimpIcons.VolumeUp
} else {
SimpIcons.VolumeOff
},
contentDescription = if (controllerState.volume > 0f) "Mute" else "Unmute",
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp),
)
}
}
}
}
@ -611,86 +618,88 @@ fun SquareMiniLayout(
Spacer(modifier = Modifier.height(12.dp))
// Main playback controls
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
// Like/Favorite button
IconButton(
onClick = { onUIEvent(UIEvent.ToggleLike) },
modifier = Modifier.size(32.dp),
LeftToRight {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector =
if (controllerState.isLiked) {
SimpIcons.Favorite
} else {
SimpIcons.FavoriteBorder
},
contentDescription = "Like",
tint =
if (controllerState.isLiked) {
Color(0xFFFF4081)
} else {
Color.White.copy(alpha = 0.7f)
},
modifier = Modifier.size(24.dp),
// Like/Favorite button
IconButton(
onClick = { onUIEvent(UIEvent.ToggleLike) },
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector =
if (controllerState.isLiked) {
SimpIcons.Favorite
} else {
SimpIcons.FavoriteBorder
},
contentDescription = "Like",
tint =
if (controllerState.isLiked) {
Color(0xFFFF4081)
} else {
Color.White.copy(alpha = 0.7f)
},
modifier = Modifier.size(24.dp),
)
}
// Previous
RippleIconButton(
imageVector = SimpIcons.SkipPrevious,
modifier = Modifier.size(36.dp),
tint = if (controllerState.isPreviousAvailable) Color.White else Color.Gray,
onClick = {
if (controllerState.isPreviousAvailable) {
onUIEvent(UIEvent.Previous)
}
},
)
}
// Previous
RippleIconButton(
imageVector = SimpIcons.SkipPrevious,
modifier = Modifier.size(36.dp),
tint = if (controllerState.isPreviousAvailable) Color.White else Color.Gray,
onClick = {
if (controllerState.isPreviousAvailable) {
onUIEvent(UIEvent.Previous)
}
},
)
// Play/Pause
PlayPauseButton(
isPlaying = controllerState.isPlaying,
modifier = Modifier.size(52.dp),
onClick = { onUIEvent(UIEvent.PlayPause) },
)
// Next
RippleIconButton(
imageVector = SimpIcons.SkipNext,
modifier = Modifier.size(36.dp),
tint = if (controllerState.isNextAvailable) Color.White else Color.Gray,
onClick = {
if (controllerState.isNextAvailable) {
onUIEvent(UIEvent.Next)
}
},
)
// Volume/Mute button
val previousVolume = rememberPreviousVolume(controllerState.volume)
IconButton(
onClick = {
// Toggle mute/unmute
val newVolume = if (controllerState.volume > 0f) 0f else previousVolume.coerceIn(0.1f, 1f)
onUIEvent(UIEvent.UpdateVolume(newVolume))
},
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector =
if (controllerState.volume > 0f) {
SimpIcons.VolumeUp
} else {
SimpIcons.VolumeOff
},
contentDescription = if (controllerState.volume > 0f) "Mute" else "Unmute",
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(24.dp),
// Play/Pause
PlayPauseButton(
isPlaying = controllerState.isPlaying,
modifier = Modifier.size(52.dp),
onClick = { onUIEvent(UIEvent.PlayPause) },
)
// Next
RippleIconButton(
imageVector = SimpIcons.SkipNext,
modifier = Modifier.size(36.dp),
tint = if (controllerState.isNextAvailable) Color.White else Color.Gray,
onClick = {
if (controllerState.isNextAvailable) {
onUIEvent(UIEvent.Next)
}
},
)
// Volume/Mute button
val previousVolume = rememberPreviousVolume(controllerState.volume)
IconButton(
onClick = {
// Toggle mute/unmute
val newVolume = if (controllerState.volume > 0f) 0f else previousVolume.coerceIn(0.1f, 1f)
onUIEvent(UIEvent.UpdateVolume(newVolume))
},
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector =
if (controllerState.volume > 0f) {
SimpIcons.VolumeUp
} else {
SimpIcons.VolumeOff
},
contentDescription = if (controllerState.volume > 0f) "Mute" else "Unmute",
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(24.dp),
)
}
}
}
@ -811,84 +820,86 @@ fun ExpandedMiniLayout(
enter = fadeIn(tween(300, delayMillis = 150)),
exit = fadeOut(tween(200)),
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Like button
IconButton(
onClick = { onUIEvent(UIEvent.ToggleLike) },
modifier = Modifier.size(28.dp),
LeftToRight {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector =
if (controllerState.isLiked) {
SimpIcons.Favorite
} else {
SimpIcons.FavoriteBorder
},
contentDescription = "Like",
tint =
if (controllerState.isLiked) {
Color(0xFFFF4081)
} else {
Color.White.copy(alpha = 0.7f)
},
modifier = Modifier.size(20.dp),
// Like button
IconButton(
onClick = { onUIEvent(UIEvent.ToggleLike) },
modifier = Modifier.size(28.dp),
) {
Icon(
imageVector =
if (controllerState.isLiked) {
SimpIcons.Favorite
} else {
SimpIcons.FavoriteBorder
},
contentDescription = "Like",
tint =
if (controllerState.isLiked) {
Color(0xFFFF4081)
} else {
Color.White.copy(alpha = 0.7f)
},
modifier = Modifier.size(20.dp),
)
}
RippleIconButton(
imageVector = SimpIcons.SkipPrevious,
modifier = Modifier.size(28.dp),
tint = if (controllerState.isPreviousAvailable) Color.White else Color.Gray,
onClick = {
if (controllerState.isPreviousAvailable) {
onUIEvent(UIEvent.Previous)
}
},
)
}
RippleIconButton(
imageVector = SimpIcons.SkipPrevious,
modifier = Modifier.size(28.dp),
tint = if (controllerState.isPreviousAvailable) Color.White else Color.Gray,
onClick = {
if (controllerState.isPreviousAvailable) {
onUIEvent(UIEvent.Previous)
}
},
)
PlayPauseButton(
isPlaying = controllerState.isPlaying,
modifier = Modifier.size(40.dp),
onClick = {
onUIEvent(UIEvent.PlayPause)
},
)
RippleIconButton(
imageVector = SimpIcons.SkipNext,
modifier = Modifier.size(32.dp),
tint = if (controllerState.isNextAvailable) Color.White else Color.Gray,
onClick = {
if (controllerState.isNextAvailable) {
onUIEvent(UIEvent.Next)
}
},
)
// Volume button
val previousVolume = rememberPreviousVolume(controllerState.volume)
IconButton(
onClick = {
// Toggle mute/unmute
val newVolume = if (controllerState.volume > 0f) 0f else previousVolume.coerceIn(0.1f, 1f)
onUIEvent(UIEvent.UpdateVolume(newVolume))
},
modifier = Modifier.size(28.dp),
) {
Icon(
imageVector =
if (controllerState.volume > 0f) {
SimpIcons.VolumeUp
} else {
SimpIcons.VolumeOff
},
contentDescription = if (controllerState.volume > 0f) "Mute" else "Unmute",
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp),
PlayPauseButton(
isPlaying = controllerState.isPlaying,
modifier = Modifier.size(40.dp),
onClick = {
onUIEvent(UIEvent.PlayPause)
},
)
RippleIconButton(
imageVector = SimpIcons.SkipNext,
modifier = Modifier.size(32.dp),
tint = if (controllerState.isNextAvailable) Color.White else Color.Gray,
onClick = {
if (controllerState.isNextAvailable) {
onUIEvent(UIEvent.Next)
}
},
)
// Volume button
val previousVolume = rememberPreviousVolume(controllerState.volume)
IconButton(
onClick = {
// Toggle mute/unmute
val newVolume = if (controllerState.volume > 0f) 0f else previousVolume.coerceIn(0.1f, 1f)
onUIEvent(UIEvent.UpdateVolume(newVolume))
},
modifier = Modifier.size(28.dp),
) {
Icon(
imageVector =
if (controllerState.volume > 0f) {
SimpIcons.VolumeUp
} else {
SimpIcons.VolumeOff
},
contentDescription = if (controllerState.volume > 0f) "Mute" else "Unmute",
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp),
)
}
}
}
}