feat(player): rework the Apple Music player after BitChord with a lyric strip, output switcher and mesh page, and bump core
This commit is contained in:
parent
b967fda6be
commit
7621407571
26 changed files with 2311 additions and 311 deletions
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -887,6 +887,17 @@ if (getPlatform() == Platform.Android) {
|
|||
- **Now Playing video frames take the video's shape (2026-09-25)**: the frame around an inline video was a hard 16:9 in all three styles, so a 4:3 video sat pillarboxed and a vertical one shrank to a sliver. Every style now reads one value, `NowPlayingContentState.videoAspectRatio` (16:9 until known), and fits the frame into the space that style already reserves for artwork — never more, so the one-screen layout cannot grow. Spotify fits it inside the square slot; the hidden thumbnail under it takes the same shape while the video plays, because its box is what casts the artwork shadow — left at 16:9, that shadow ringed a tall video. M3E's card takes the video's shape inside a slot capped at square, and `expressiveCardSlotRatio()` is the ONE expression both the card and its measuring spacer use. Apple Music fits it into the zone above the controls, starting 16dp below the top chrome (status bar + 28dp grabber on Android, the glass dismiss button on Desktop), because the frame's top-right corner carries the fullscreen button. That zone is the page's own height minus the controls, NOT `artworkZoneHeightDp`: that value is measured against the window, and the Desktop side panel is shorter than the window by its shell padding, so a tall frame ran into the gap above the title.
|
||||
- The value comes from `rememberVideoAspectRatio(playerName)` (expect/actual). Android reads Media3's `rememberPresentationState(player).videoSizeDp`. Desktop reads mpv's `video-params/aspect`, observed by `MpvPlayer` and published as `MpvVideoFrameSource.aspectRatio` — the frames cannot say it themselves, since mpv letterboxes them to the target size. Living on the frame source, it is re-pointed by `MpvPlayerAdapter.currentVideoFrames` on every handle swap for free.
|
||||
- **The Full build's session player only reports what changed.** There `MAIN_PLAYER` is a Media3 `CastPlayer` (`ForwardingSimpleBasePlayer` → `SimpleBasePlayer`): it re-reads the wrapped player on `onEvents` and fires a callback only for fields that differ from its last snapshot. A view composed mid-video — Now Playing reopened after fullscreen — therefore never hears `onVideoSizeChanged`, `onTracksChanged` or `onMediaItemTransition` for the current item. `MediaPlayerViewWithSubtitle` started at `videoRatio = 16f / 9` and waited for that event, so the video came back stretched to 16:9 ever since Cast wrapping landed (core `c310577`, 2026-07-10); FOSS passes events straight through and healed on the next frame. UI state derived from the session player must be read from the player's getters (or Media3's `rememberPresentationState`) before listening.
|
||||
- **Apple Music style: uncropped artwork, lyric strip, output capsule, mesh page (2026-09-30)**: MAIN was reworked after BitChord (`kushagrasinghx/BitChord`, GPL-3), whose Now Playing the owner pointed at. The controls block now hangs from the bottom of the page, and the artwork is a full-width **square** at the top (`minOf(width, height × 0.62)`), so a cover is no longer cropped by roughly a quarter of its width. The space left between the artwork and the title is page, not artwork.
|
||||
- **Apple Music's animated artwork is no longer a fullscreen canvas.** It plays in a frame of its own shape (3:4 or 1:1) at full width, top-aligned, with the controls exactly where the still cover put them — they must not jump when the clip arrives a few seconds in — so a tall clip's lower part fades out behind the title. The clip's still (`CanvasData.thumbUrl`, newly carried from `canvasThumbUrl`) sits under it, sizes the frame and fades out as the clip fades in. Only a **Spotify** canvas (`.mp4`, 9:16) still takes the whole page and auto-hides the controls; `isAnimatedArtwork()` tells them apart by the `.m3u8` Apple serves. The shell's auto-hide still fires for any canvas, so the style ignores it for animated artwork. That canvas's bottom scrim is sized off the measured controls block (the controls fill its lower 60%), so the title sits at the same depth of the ramp however tall the block grows.
|
||||
- **The clip cannot be masked, so the page is laid over it.** Media3's `PlayerSurface` is used with `SURFACE_TYPE_SURFACE_VIEW` (`media3-ui/MediaPlayerView.kt`), and a SurfaceView is composited under the window: no Compose `alpha`, `DstIn` mask or offscreen layer reaches its pixels. Masked like the cover, the clip ended in a hard edge right under the title. Instead `AppleMusicMainView` draws a second `AppleMusicMeshBackdrop` — same mesh, same size, same seam, so it matches the page beneath it pixel for pixel — ABOVE the pager, faded in with `appleMusicFadeBetween` over the stretch the cover dissolves over, ending at the clip's bottom edge. Compose content drawn above a SurfaceView does cover it, which is what makes this work on every surface type; the frame itself carries no mask, so Desktop (where mpv frames are plain Compose images) gets the identical fade.
|
||||
- **The band under the status bar (Android)** is blurred and darkened so white status-bar glyphs and the grabber survive a bright sleeve. Both follow BitChord exactly — the owner rejected a first version (grabber + 40dp long, on a smoothstep ramp) as too dark and too long. The band is the status bar + 32dp (BitChord's `DISMISS_STRIP_HEIGHT`). The blur is `TopBandBlur`: a `Modifier.blur(32.dp)` copy of the picture inside each pager page, solid under the status bar and gone at the band's end — the clip's band uses its still, since nothing blurs the video surface. The wash is BitChord's curve, black at `alpha × (1 − p)^1.5` (sampled at 24 stops instead of its 8), with alpha in its `topBandScrimAlpha` range, 16% on a black sleeve to 65% on a white one, driven by `AppleMusicMesh.topLuminance` (the top grid row, read before lifting).
|
||||
- **The page is a mesh of the artwork's own colours** (`AppleMusicMeshBackdrop`, ported from BitChord's `ArtworkMeshBackdrop`): a 6×6 grid of means read bottom-up, rows below the seam shifted sideways by a seeded amount, smoothstep-resampled to 32×32 and drawn from the artwork's bottom edge (the first row is held above it), blurred 44dp, crossfaded 900ms on a skip. The colour directly under the artwork is the colour it ended on, so there is no seam to hide. LYRICS and QUEUE keep the frosted cover. Pixels are read through `toReadableBitmap()` (expect/actual) because Coil hands back HARDWARE bitmaps on Android, which throw when read or drawn into a software canvas.
|
||||
- **Every fade is smoothstep** — `appleMusicVerticalFadeEdges` now builds 24 stops on the curve `smoothScrimBrush` uses; the linear ramp it had left a visible line where the artwork started to dissolve. The owner asked for this twice.
|
||||
- **Lyric strip over the progress bar** (`AppleMusicLyricStrip`): the line being sung in `labelSmall` white — the role Classic and M3E use for their inline line — rising in over 340ms as the old one rises away. Word-synced lines are lit as they are sung by drawing the line twice and clipping the bright copy to the playhead in the draw phase, which is what lets the light travel inside a word. The playhead is the strip's own `rememberSweepPlayhead`, not LyricsView's `rememberSmoothPlayhead`: that one restarts from every 50 ms report, and since reports land a frame or two late and never on a frame, a continuous sweep stalled and jumped several times a second. The sweep clock runs on the frame clock, only leans toward the reports (time constant 150 ms), never runs backwards, and snaps only past 500 ms (a seek). Before the first line starts the slot holds three dots. One line tall in every state, so nothing below it moves. Tap opens the Lyrics tab.
|
||||
- **Text**: the main title row keeps M3E's roles (`titleMedium`/`bodyMedium`, 18/13sp) — one role up (`titleLarge`/`bodyLarge`, 25/18sp) was tried and rejected as far too big, since the app's scale jumps 18 → 25. Secondary text (artist, times, output caption, compact artist, queue subtitles) is BitChord's `White @55%`, not `typo()`'s body colour: under `ForceDarkContent` that is an opaque `#A8A8A8`, darker than a bright sleeve's page, so it read as a dark grey smudge there. Both sliders' track is 8.4dp (16.8dp while held), 20% thicker than before.
|
||||
- **Quality line instead of the codec badge**: "154 kbps · 48 kHz" from the stream's own `NewFormatEntity.bitrate`/`sampleRate` (`toAudioQualityLabel`), behind a 13dp headphones glyph, pinned to the row's centre — the three-way weighted row wrapped it onto two lines. `audioCodecLabel` is gone.
|
||||
- **Dock → action row**: Lyrics · [Output | Listen Together] · Queue, with the output's name under it. The output half opens `AppleMusicOutputSheet` (the ⋯ sheet's card, handle and rows), where the device's own outputs and the **Cast receivers are one list** — Cast moved out of the dock into the sheet. The Listen Together half is highlighted while a room is live and navigates to its screen.
|
||||
- **Per-app output routing** — `MediaPlayerInterface.audioOutputs`/`selectAudioOutput`/`refreshAudioOutputs` (default: empty/no-op). Android: `AudioOutputRouter` lists outputs through `AudioManager` + `AudioDeviceCallback` and routes with `ExoPlayer.setPreferredAudioDevice` on every player (`createExoPlayerInstance` hands the pick to each new one). This routes the app's player only, not the phone. Picking what the system already uses clears the preference, so a headset plugged in later still wins. Desktop: mpv's `audio-device-list`/`audio-device`, applied to every live handle and re-applied in `applyPlaybackLevels`. **Only the pinned driver's devices are offered**: mpv lists every driver together, and naming a `coreaudio/…` device would switch a macOS handle back to the driver that crashes the process on hotplug (see "macOS desktop audio moved to `ao=avfoundation`"). Cast receivers come from `rememberCastRoutes` in the `cast` module (MediaRouter with `CastContext.mergedSelector`; `selectRoute` starts the session and `unselect(UNSELECT_REASON_STOPPED)` ends it, per Google's "Custom Cast Dialogs"), with a no-op twin in `cast-empty`.
|
||||
|
||||
## 🔄 CLAUDE.md Auto-Update Rule (MANDATORY)
|
||||
|
||||
|
|
@ -912,6 +923,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-09-26
|
||||
**Last updated**: 2026-09-30
|
||||
**Project version**: Check latest release on GitHub
|
||||
**Maintained by**: maxrave-dev and contributors
|
||||
|
|
|
|||
|
|
@ -31,3 +31,23 @@ fun CastIconButton(
|
|||
) {
|
||||
// No-op: Google Cast is not available in this build flavor.
|
||||
}
|
||||
|
||||
data class CastRouteInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val isSelected: Boolean,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun rememberCastRoutes(discover: Boolean): List<CastRouteInfo> = emptyList()
|
||||
|
||||
fun selectCastRoute(
|
||||
context: Context,
|
||||
id: String,
|
||||
) {
|
||||
// No-op: Google Cast is not available in this build flavor.
|
||||
}
|
||||
|
||||
fun stopCasting(context: Context) {
|
||||
// No-op: Google Cast is not available in this build flavor.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package org.simpmusic.cast
|
|||
import android.content.Context
|
||||
import androidx.media3.cast.CastPlayer
|
||||
import androidx.media3.common.Player
|
||||
import androidx.mediarouter.media.MediaRouteSelector
|
||||
import com.google.android.gms.cast.framework.CastContext
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
|
|
@ -69,6 +70,15 @@ fun wrapWithCastPlayer(
|
|||
}
|
||||
}
|
||||
|
||||
/** The route selector the Cast framework registered for this app's receiver, or null without Cast. */
|
||||
internal fun castRouteSelector(): MediaRouteSelector? =
|
||||
try {
|
||||
castContext?.mergedSelector
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to read the Cast route selector: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
|
||||
fun currentCastDeviceName(): String? =
|
||||
try {
|
||||
castContext
|
||||
|
|
|
|||
91
cast/src/main/java/org/simpmusic/cast/CastRoutes.kt
Normal file
91
cast/src/main/java/org/simpmusic/cast/CastRoutes.kt
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package org.simpmusic.cast
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.mediarouter.media.MediaRouter
|
||||
|
||||
/** A Cast receiver on the network. [id] is its MediaRouter route id; [isSelected] while playback is on it. */
|
||||
data class CastRouteInfo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val isSelected: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* The Cast receivers this app can play to, for a picker of its own rather than the stock chooser
|
||||
* dialog. Scanning the network is only requested while [discover] is true — the list is on screen —
|
||||
* because discovery keeps the radio busy. Empty when Cast is unavailable.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberCastRoutes(discover: Boolean): List<CastRouteInfo> {
|
||||
val context = LocalContext.current
|
||||
val selector = remember { castRouteSelector() } ?: return emptyList()
|
||||
val router = remember(context) { MediaRouter.getInstance(context.applicationContext) }
|
||||
var routes by remember { mutableStateOf(emptyList<CastRouteInfo>()) }
|
||||
DisposableEffect(router, selector, discover) {
|
||||
fun read() {
|
||||
routes =
|
||||
router.routes
|
||||
.filter { !it.isDefault && it.isEnabled && it.matchesSelector(selector) }
|
||||
.map { CastRouteInfo(id = it.id, name = it.name, isSelected = it.isSelected) }
|
||||
}
|
||||
val callback =
|
||||
object : MediaRouter.Callback() {
|
||||
override fun onRouteAdded(
|
||||
router: MediaRouter,
|
||||
route: MediaRouter.RouteInfo,
|
||||
) = read()
|
||||
|
||||
override fun onRouteRemoved(
|
||||
router: MediaRouter,
|
||||
route: MediaRouter.RouteInfo,
|
||||
) = read()
|
||||
|
||||
override fun onRouteChanged(
|
||||
router: MediaRouter,
|
||||
route: MediaRouter.RouteInfo,
|
||||
) = read()
|
||||
|
||||
override fun onRouteSelected(
|
||||
router: MediaRouter,
|
||||
selectedRoute: MediaRouter.RouteInfo,
|
||||
reason: Int,
|
||||
requestedRoute: MediaRouter.RouteInfo,
|
||||
) = read()
|
||||
|
||||
override fun onRouteUnselected(
|
||||
router: MediaRouter,
|
||||
route: MediaRouter.RouteInfo,
|
||||
reason: Int,
|
||||
) = read()
|
||||
}
|
||||
router.addCallback(selector, callback, if (discover) MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY else 0)
|
||||
read()
|
||||
onDispose { router.removeCallback(callback) }
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand playback to the receiver [id]. Selecting the route is all a custom picker does: the Cast
|
||||
* SDK's SessionManager watches MediaRouter selection and starts the session itself — see
|
||||
* developers.google.com/cast/docs/android_sender/advanced, "Custom Cast Dialogs".
|
||||
*/
|
||||
fun selectCastRoute(
|
||||
context: Context,
|
||||
id: String,
|
||||
) {
|
||||
val router = MediaRouter.getInstance(context.applicationContext)
|
||||
router.routes.firstOrNull { it.id == id }?.let(router::selectRoute)
|
||||
}
|
||||
|
||||
/** Stop casting and bring playback back here; SessionManager ends the session when the route is unselected. */
|
||||
fun stopCasting(context: Context) {
|
||||
MediaRouter.getInstance(context.applicationContext).unselect(MediaRouter.UNSELECT_REASON_STOPPED)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.maxrave.simpmusic.expect.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Bitmap.CompressFormat.JPEG
|
||||
import android.graphics.Bitmap.CompressFormat.PNG
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
|
|
@ -24,3 +25,12 @@ actual fun ImageBitmap.toPngByteArray(): ByteArray? {
|
|||
}
|
||||
|
||||
actual fun Image.toImageBitmap(): ImageBitmap = this.toBitmap().asImageBitmap()
|
||||
|
||||
actual fun ImageBitmap.toReadableBitmap(
|
||||
width: Int,
|
||||
height: Int,
|
||||
): ImageBitmap {
|
||||
val source = asAndroidBitmap()
|
||||
val readable = if (source.config == Bitmap.Config.HARDWARE) source.copy(Bitmap.Config.ARGB_8888, false) else source
|
||||
return Bitmap.createScaledBitmap(readable, width, height, true).asImageBitmap()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@ package com.maxrave.simpmusic.expect.ui
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import org.simpmusic.cast.CastIconButton
|
||||
import org.simpmusic.cast.isCastAvailable
|
||||
import org.simpmusic.cast.rememberCastRoutes
|
||||
import org.simpmusic.cast.selectCastRoute
|
||||
import org.simpmusic.cast.stopCasting
|
||||
|
||||
@Composable
|
||||
actual fun PlatformCastButton(
|
||||
|
|
@ -15,3 +19,15 @@ actual fun PlatformCastButton(
|
|||
}
|
||||
|
||||
actual fun isPlatformCastAvailable(): Boolean = isCastAvailable()
|
||||
|
||||
@Composable
|
||||
actual fun rememberCastReceivers(discover: Boolean): CastReceivers {
|
||||
val context = LocalContext.current
|
||||
val routes = rememberCastRoutes(discover)
|
||||
return CastReceivers(
|
||||
receivers = routes.map { CastReceiver(id = it.id, name = it.name, isConnected = it.isSelected) },
|
||||
connect = { id -> selectCastRoute(context, id) },
|
||||
disconnect = { stopCasting(context) },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -730,6 +730,14 @@
|
|||
<string name="listen_to_downloaded">Listen to downloaded music</string>
|
||||
<string name="playing_on_device">Playing on %1$s</string>
|
||||
<string name="not_available_while_casting">Not available while casting</string>
|
||||
<string name="audio_output">Audio output</string>
|
||||
<string name="audio_output_playing_here">Playing here</string>
|
||||
<string name="audio_output_cast">Cast</string>
|
||||
<string name="audio_output_system_default">System default</string>
|
||||
<string name="audio_output_wired">Wired headphones</string>
|
||||
<string name="audio_output_bluetooth">Bluetooth</string>
|
||||
<string name="audio_output_usb">USB audio</string>
|
||||
<string name="audio_output_hdmi">HDMI</string>
|
||||
<string name="import_data">Import playlists</string>
|
||||
<string name="import_playlists_from_other_apps">Import playlists from another app</string>
|
||||
<string name="import_data_intro">Import brings in playlists converted from Spotify or another YT Music client app.\nConverting tool: https://www.simpmusic.org/tools\nIn SimpMusic, only playlist files in our own format can be imported</string>
|
||||
|
|
|
|||
|
|
@ -16,3 +16,14 @@ expect fun ImageBitmap.toByteArray(): ByteArray?
|
|||
expect fun ImageBitmap.toPngByteArray(): ByteArray?
|
||||
|
||||
expect fun Image.toImageBitmap(): ImageBitmap
|
||||
|
||||
/**
|
||||
* A [width]x[height] copy of this bitmap whose pixels can be read with [ImageBitmap.readPixels].
|
||||
*
|
||||
* Needed on Android because Coil hands back HARDWARE bitmaps, which keep their pixels on the GPU:
|
||||
* reading them, or drawing them into a software canvas, throws.
|
||||
*/
|
||||
expect fun ImageBitmap.toReadableBitmap(
|
||||
width: Int,
|
||||
height: Int,
|
||||
): ImageBitmap
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.maxrave.simpmusic.expect.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
|
|
@ -22,3 +24,27 @@ expect fun PlatformCastButton(
|
|||
* is false — the button hides itself, but it cannot hide a wrapper it doesn't own.
|
||||
*/
|
||||
expect fun isPlatformCastAvailable(): Boolean
|
||||
|
||||
/** A Cast receiver playback can be handed to. [isConnected] while playback is on it. */
|
||||
@Immutable
|
||||
data class CastReceiver(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val isConnected: Boolean,
|
||||
)
|
||||
|
||||
/** The receivers on the network, plus the two things a picker does with them. */
|
||||
@Stable
|
||||
class CastReceivers(
|
||||
val receivers: List<CastReceiver>,
|
||||
val connect: (id: String) -> Unit,
|
||||
val disconnect: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* Cast receivers for a picker of the app's own, scanned only while [discover] is true — i.e. while
|
||||
* the list is on screen. Always empty where Cast is not built in (desktop, the FOSS build).
|
||||
*/
|
||||
@Composable
|
||||
expect fun rememberCastReceivers(discover: Boolean): CastReceivers
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
package com.maxrave.simpmusic.ui.icon
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Suppress("CheckReturnValue")
|
||||
val SimpIcons.Headphones: ImageVector
|
||||
get() {
|
||||
if (_Headphones != null) {
|
||||
return _Headphones!!
|
||||
}
|
||||
_Headphones =
|
||||
ImageVector.Builder(
|
||||
name = "Headphones",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
)
|
||||
.apply {
|
||||
path(
|
||||
fill = SolidColor(Color.Black),
|
||||
fillAlpha = 1f,
|
||||
stroke = null,
|
||||
strokeAlpha = 1f,
|
||||
strokeLineWidth = 1f,
|
||||
strokeLineCap = StrokeCap.Butt,
|
||||
strokeLineJoin = StrokeJoin.Bevel,
|
||||
strokeLineMiter = 1f,
|
||||
pathFillType = PathFillType.Companion.NonZero,
|
||||
) {
|
||||
moveTo(7f, 21f)
|
||||
horizontalLineTo(5f)
|
||||
quadTo(4.18f, 21f, 3.59f, 20.41f)
|
||||
reflectiveQuadTo(3f, 19f)
|
||||
verticalLineTo(12f)
|
||||
quadTo(3f, 10.13f, 3.71f, 8.49f)
|
||||
reflectiveQuadTo(5.64f, 5.64f)
|
||||
quadTo(6.85f, 4.42f, 8.49f, 3.71f)
|
||||
reflectiveQuadTo(12f, 3f)
|
||||
reflectiveQuadToRelative(3.51f, 0.71f)
|
||||
reflectiveQuadToRelative(2.85f, 1.93f)
|
||||
reflectiveQuadToRelative(1.93f, 2.85f)
|
||||
reflectiveQuadTo(21f, 12f)
|
||||
verticalLineToRelative(7f)
|
||||
quadToRelative(0f, 0.82f, -0.59f, 1.41f)
|
||||
reflectiveQuadTo(19f, 21f)
|
||||
horizontalLineTo(17f)
|
||||
quadToRelative(-0.82f, 0f, -1.41f, -0.59f)
|
||||
reflectiveQuadTo(15f, 19f)
|
||||
verticalLineTo(15f)
|
||||
quadToRelative(0f, -0.83f, 0.59f, -1.41f)
|
||||
reflectiveQuadTo(17f, 13f)
|
||||
horizontalLineToRelative(2f)
|
||||
verticalLineTo(12f)
|
||||
quadTo(19f, 9.07f, 16.96f, 7.04f)
|
||||
reflectiveQuadTo(12f, 5f)
|
||||
quadTo(9.08f, 5f, 7.04f, 7.04f)
|
||||
reflectiveQuadTo(5f, 12f)
|
||||
verticalLineToRelative(1f)
|
||||
horizontalLineTo(7f)
|
||||
quadToRelative(0.83f, 0f, 1.41f, 0.59f)
|
||||
quadTo(9f, 14.18f, 9f, 15f)
|
||||
verticalLineToRelative(4f)
|
||||
quadToRelative(0f, 0.82f, -0.59f, 1.41f)
|
||||
quadTo(7.83f, 21f, 7f, 21f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
return _Headphones!!
|
||||
}
|
||||
|
||||
private var _Headphones: ImageVector? = null
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package com.maxrave.simpmusic.ui.icon
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Suppress("CheckReturnValue")
|
||||
val SimpIcons.MusicCast: ImageVector
|
||||
get() {
|
||||
if (_MusicCast != null) {
|
||||
return _MusicCast!!
|
||||
}
|
||||
_MusicCast =
|
||||
ImageVector.Builder(
|
||||
name = "MusicCast",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
)
|
||||
.apply {
|
||||
path(
|
||||
fill = SolidColor(Color.Black),
|
||||
fillAlpha = 1f,
|
||||
stroke = null,
|
||||
strokeAlpha = 1f,
|
||||
strokeLineWidth = 1f,
|
||||
strokeLineCap = StrokeCap.Butt,
|
||||
strokeLineJoin = StrokeJoin.Bevel,
|
||||
strokeLineMiter = 1f,
|
||||
pathFillType = PathFillType.Companion.NonZero,
|
||||
) {
|
||||
moveTo(11.18f, 18.83f)
|
||||
quadTo(10f, 17.65f, 10f, 16f)
|
||||
reflectiveQuadToRelative(1.18f, -2.83f)
|
||||
reflectiveQuadTo(14f, 12f)
|
||||
quadToRelative(0.58f, 0f, 1.06f, 0.14f)
|
||||
reflectiveQuadTo(16f, 12.55f)
|
||||
verticalLineTo(5f)
|
||||
quadTo(16f, 4.57f, 16.29f, 4.29f)
|
||||
reflectiveQuadTo(17f, 4f)
|
||||
horizontalLineToRelative(4f)
|
||||
quadToRelative(0.43f, 0f, 0.71f, 0.29f)
|
||||
reflectiveQuadTo(22f, 5f)
|
||||
verticalLineTo(6f)
|
||||
quadToRelative(0f, 0.43f, -0.29f, 0.71f)
|
||||
reflectiveQuadTo(21f, 7f)
|
||||
horizontalLineTo(18f)
|
||||
verticalLineToRelative(9f)
|
||||
quadToRelative(0f, 1.65f, -1.18f, 2.82f)
|
||||
reflectiveQuadTo(14f, 20f)
|
||||
reflectiveQuadTo(11.18f, 18.83f)
|
||||
close()
|
||||
moveTo(5f, 11.66f)
|
||||
quadTo(4.25f, 13.2f, 4.05f, 14.95f)
|
||||
quadTo(4f, 15.38f, 3.71f, 15.69f)
|
||||
reflectiveQuadTo(3f, 16f)
|
||||
reflectiveQuadTo(2.29f, 15.68f)
|
||||
reflectiveQuadTo(2.05f, 14.93f)
|
||||
quadToRelative(0.2f, -2.17f, 1.11f, -4.06f)
|
||||
quadTo(4.08f, 8.98f, 5.53f, 7.52f)
|
||||
reflectiveQuadTo(8.86f, 5.16f)
|
||||
reflectiveQuadTo(12.93f, 4.05f)
|
||||
quadTo(13.35f, 4f, 13.68f, 4.29f)
|
||||
reflectiveQuadTo(14f, 5f)
|
||||
reflectiveQuadTo(13.69f, 5.71f)
|
||||
reflectiveQuadTo(12.95f, 6.05f)
|
||||
quadTo(11.2f, 6.25f, 9.68f, 6.99f)
|
||||
quadTo(8.15f, 7.72f, 6.95f, 8.92f)
|
||||
reflectiveQuadTo(5f, 11.66f)
|
||||
close()
|
||||
moveToRelative(3.68f, 1.57f)
|
||||
quadToRelative(-0.45f, 0.84f, -0.6f, 1.79f)
|
||||
quadTo(8f, 15.45f, 7.71f, 15.73f)
|
||||
reflectiveQuadTo(7f, 16f)
|
||||
quadTo(6.58f, 16f, 6.3f, 15.69f)
|
||||
reflectiveQuadTo(6.08f, 14.95f)
|
||||
quadTo(6.25f, 13.6f, 6.84f, 12.44f)
|
||||
quadTo(7.43f, 11.27f, 8.35f, 10.35f)
|
||||
quadTo(9.28f, 9.42f, 10.44f, 8.84f)
|
||||
reflectiveQuadTo(12.95f, 8.07f)
|
||||
quadTo(13.38f, 8.02f, 13.69f, 8.3f)
|
||||
reflectiveQuadTo(14f, 9f)
|
||||
quadToRelative(0f, 0.42f, -0.27f, 0.71f)
|
||||
reflectiveQuadToRelative(-0.7f, 0.36f)
|
||||
quadToRelative(-0.95f, 0.15f, -1.78f, 0.59f)
|
||||
reflectiveQuadTo(9.78f, 11.75f)
|
||||
reflectiveQuadToRelative(-1.1f, 1.49f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
return _MusicCast!!
|
||||
}
|
||||
|
||||
private var _MusicCast: ImageVector? = null
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package com.maxrave.simpmusic.ui.icon
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Suppress("CheckReturnValue")
|
||||
val SimpIcons.Smartphone: ImageVector
|
||||
get() {
|
||||
if (_Smartphone != null) {
|
||||
return _Smartphone!!
|
||||
}
|
||||
_Smartphone =
|
||||
ImageVector.Builder(
|
||||
name = "Smartphone",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
)
|
||||
.apply {
|
||||
path(
|
||||
fill = SolidColor(Color.Black),
|
||||
fillAlpha = 1f,
|
||||
stroke = null,
|
||||
strokeAlpha = 1f,
|
||||
strokeLineWidth = 1f,
|
||||
strokeLineCap = StrokeCap.Butt,
|
||||
strokeLineJoin = StrokeJoin.Bevel,
|
||||
strokeLineMiter = 1f,
|
||||
pathFillType = PathFillType.Companion.NonZero,
|
||||
) {
|
||||
moveTo(7f, 23f)
|
||||
quadTo(6.18f, 23f, 5.59f, 22.41f)
|
||||
reflectiveQuadTo(5f, 21f)
|
||||
verticalLineTo(3f)
|
||||
quadTo(5f, 2.17f, 5.59f, 1.59f)
|
||||
reflectiveQuadTo(7f, 1f)
|
||||
horizontalLineTo(17f)
|
||||
quadToRelative(0.82f, 0f, 1.41f, 0.59f)
|
||||
reflectiveQuadTo(19f, 3f)
|
||||
verticalLineTo(6.1f)
|
||||
quadToRelative(0.45f, 0.18f, 0.73f, 0.55f)
|
||||
reflectiveQuadTo(20f, 7.5f)
|
||||
verticalLineToRelative(2f)
|
||||
quadToRelative(0f, 0.47f, -0.27f, 0.85f)
|
||||
reflectiveQuadTo(19f, 10.9f)
|
||||
verticalLineTo(21f)
|
||||
quadToRelative(0f, 0.82f, -0.59f, 1.41f)
|
||||
reflectiveQuadTo(17f, 23f)
|
||||
horizontalLineTo(7f)
|
||||
close()
|
||||
moveTo(12.71f, 5.71f)
|
||||
quadTo(13f, 5.43f, 13f, 5f)
|
||||
reflectiveQuadTo(12.71f, 4.29f)
|
||||
reflectiveQuadTo(12f, 4f)
|
||||
reflectiveQuadTo(11.29f, 4.29f)
|
||||
reflectiveQuadTo(11f, 5f)
|
||||
reflectiveQuadToRelative(0.29f, 0.71f)
|
||||
reflectiveQuadTo(12f, 6f)
|
||||
reflectiveQuadTo(12.71f, 5.71f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
return _Smartphone!!
|
||||
}
|
||||
|
||||
private var _Smartphone: ImageVector? = null
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package com.maxrave.simpmusic.ui.icon
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Suppress("CheckReturnValue")
|
||||
val SimpIcons.Speaker: ImageVector
|
||||
get() {
|
||||
if (_Speaker != null) {
|
||||
return _Speaker!!
|
||||
}
|
||||
_Speaker =
|
||||
ImageVector.Builder(
|
||||
name = "Speaker",
|
||||
defaultWidth = 24.dp,
|
||||
defaultHeight = 24.dp,
|
||||
viewportWidth = 24f,
|
||||
viewportHeight = 24f,
|
||||
)
|
||||
.apply {
|
||||
path(
|
||||
fill = SolidColor(Color.Black),
|
||||
fillAlpha = 1f,
|
||||
stroke = null,
|
||||
strokeAlpha = 1f,
|
||||
strokeLineWidth = 1f,
|
||||
strokeLineCap = StrokeCap.Butt,
|
||||
strokeLineJoin = StrokeJoin.Bevel,
|
||||
strokeLineMiter = 1f,
|
||||
pathFillType = PathFillType.Companion.NonZero,
|
||||
) {
|
||||
moveTo(17f, 22f)
|
||||
horizontalLineTo(7f)
|
||||
quadTo(6.18f, 22f, 5.59f, 21.41f)
|
||||
reflectiveQuadTo(5f, 20f)
|
||||
verticalLineTo(4f)
|
||||
quadTo(5f, 3.17f, 5.59f, 2.59f)
|
||||
reflectiveQuadTo(7f, 2f)
|
||||
horizontalLineTo(17f)
|
||||
quadToRelative(0.82f, 0f, 1.41f, 0.59f)
|
||||
reflectiveQuadTo(19f, 4f)
|
||||
verticalLineTo(20f)
|
||||
quadToRelative(0f, 0.82f, -0.59f, 1.41f)
|
||||
reflectiveQuadTo(17f, 22f)
|
||||
close()
|
||||
moveTo(13.41f, 8.41f)
|
||||
quadTo(14f, 7.82f, 14f, 7f)
|
||||
reflectiveQuadTo(13.41f, 5.59f)
|
||||
reflectiveQuadTo(12f, 5f)
|
||||
reflectiveQuadTo(10.59f, 5.59f)
|
||||
quadTo(10f, 6.18f, 10f, 7f)
|
||||
reflectiveQuadToRelative(0.59f, 1.41f)
|
||||
reflectiveQuadTo(12f, 9f)
|
||||
reflectiveQuadTo(13.41f, 8.41f)
|
||||
close()
|
||||
moveToRelative(1.41f, 9.41f)
|
||||
quadTo(16f, 16.65f, 16f, 15f)
|
||||
reflectiveQuadTo(14.83f, 12.18f)
|
||||
reflectiveQuadTo(12f, 11f)
|
||||
reflectiveQuadTo(9.18f, 12.18f)
|
||||
reflectiveQuadTo(8f, 15f)
|
||||
reflectiveQuadToRelative(1.18f, 2.82f)
|
||||
reflectiveQuadTo(12f, 19f)
|
||||
reflectiveQuadToRelative(2.83f, -1.18f)
|
||||
close()
|
||||
moveTo(10.59f, 16.41f)
|
||||
quadTo(10f, 15.83f, 10f, 15f)
|
||||
reflectiveQuadToRelative(0.59f, -1.41f)
|
||||
reflectiveQuadTo(12f, 13f)
|
||||
reflectiveQuadToRelative(1.41f, 0.59f)
|
||||
quadTo(14f, 14.18f, 14f, 15f)
|
||||
reflectiveQuadToRelative(-0.59f, 1.41f)
|
||||
reflectiveQuadTo(12f, 17f)
|
||||
reflectiveQuadTo(10.59f, 16.41f)
|
||||
close()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
return _Speaker!!
|
||||
}
|
||||
|
||||
private var _Speaker: ImageVector? = null
|
||||
|
|
@ -84,6 +84,7 @@ import com.maxrave.simpmusic.ui.component.QueueBottomSheet
|
|||
import com.maxrave.simpmusic.ui.component.VoteLyricsDialog
|
||||
import com.maxrave.simpmusic.ui.icon.KeyboardArrowDown
|
||||
import com.maxrave.simpmusic.ui.icon.SimpIcons
|
||||
import com.maxrave.simpmusic.ui.navigation.destination.home.ListenTogetherDestination
|
||||
import com.maxrave.simpmusic.ui.navigation.destination.list.ArtistDestination
|
||||
import com.maxrave.simpmusic.ui.navigation.destination.player.FullscreenDestination
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.NowPlayingContentActions
|
||||
|
|
@ -92,7 +93,7 @@ import com.maxrave.simpmusic.ui.screen.player.content.NowPlayingContentM3Express
|
|||
import com.maxrave.simpmusic.ui.screen.player.content.NowPlayingContentSpotify
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.NowPlayingContentState
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.PlayerBackdropColor
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.toAudioCodecLabel
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.toAudioQualityLabel
|
||||
import com.maxrave.simpmusic.viewModel.LyricsProvider
|
||||
import com.maxrave.simpmusic.viewModel.NowPlayingBottomSheetUIEvent
|
||||
import com.maxrave.simpmusic.viewModel.NowPlayingBottomSheetViewModel
|
||||
|
|
@ -184,7 +185,7 @@ fun NowPlayingScreenContent(
|
|||
val lyricsOffsetMs by sharedViewModel.getLyricsOffsetMs().collectAsStateWithLifecycle(0)
|
||||
val likeStatus by sharedViewModel.likeStatus.collectAsStateWithLifecycle()
|
||||
val castState by sharedViewModel.castState.collectAsStateWithLifecycle()
|
||||
// Apple Music style's progress-bar codec badge — see NowPlayingContentState.toAudioCodecLabel.
|
||||
// Apple Music style's quality line under the progress bar — see NowPlayingContentState.toAudioQualityLabel.
|
||||
val formatState by sharedViewModel.format.collectAsStateWithLifecycle(initialValue = null)
|
||||
|
||||
val shouldShowVideo by sharedViewModel.getVideo.collectAsStateWithLifecycle()
|
||||
|
|
@ -626,11 +627,8 @@ fun NowPlayingScreenContent(
|
|||
mainScrollState = mainScrollState,
|
||||
isExpanded = isExpanded,
|
||||
dismissIcon = dismissIcon,
|
||||
// codecs, NOT mimeType. StreamRepositoryImpl splits YouTube's
|
||||
// `audio/webm; codecs="opus"` with a regex and stores the two halves in SEPARATE
|
||||
// columns: mimeType keeps "audio/webm", codecs keeps "opus". Asking mimeType for the
|
||||
// codec therefore never matched anything and the badge never rendered, on any track.
|
||||
audioCodecLabel = formatState?.codecs.toAudioCodecLabel(),
|
||||
audioQualityLabel = formatState.toAudioQualityLabel(),
|
||||
lyricsOffsetMs = lyricsOffsetMs.toLong(),
|
||||
videoAspectRatio = rememberVideoAspectRatio(MAIN_PLAYER) ?: 16f / 9,
|
||||
)
|
||||
val actions =
|
||||
|
|
@ -668,6 +666,10 @@ fun NowPlayingScreenContent(
|
|||
)
|
||||
}
|
||||
},
|
||||
onOpenListenTogether = {
|
||||
onDismiss()
|
||||
navController.navigate(ListenTogetherDestination)
|
||||
},
|
||||
onAddToYouTubeLiked = { sharedViewModel.addToYouTubeLiked() },
|
||||
onShowMoreSheet = { showSheet = true },
|
||||
onShowQueue = { showQueueBottomSheet = true },
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import androidx.compose.foundation.background
|
|||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -25,12 +24,12 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
|
|
@ -43,6 +42,7 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -50,19 +50,21 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.CachePolicy
|
||||
|
|
@ -70,6 +72,7 @@ import coil3.request.ImageRequest
|
|||
import coil3.request.crossfade
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import com.maxrave.common.Config.MAIN_PLAYER
|
||||
import com.maxrave.domain.mediaservice.handler.MediaPlayerHandler
|
||||
import com.maxrave.domain.mediaservice.handler.RepeatState
|
||||
import com.maxrave.simpmusic.Platform
|
||||
import com.maxrave.simpmusic.expect.ui.DeviceVolumeController
|
||||
|
|
@ -79,40 +82,46 @@ 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.getScreenSizeInfo
|
||||
import com.maxrave.simpmusic.extension.smoothScrimBrush
|
||||
import com.maxrave.simpmusic.getPlatform
|
||||
import com.maxrave.simpmusic.ui.component.ExplicitBadge
|
||||
import com.maxrave.simpmusic.ui.component.LiquidGlassIconButton
|
||||
import com.maxrave.simpmusic.ui.component.rememberHolderPainter
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.AppleMusicBottomCluster
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.AppleMusicHeaderActions
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.AppleMusicLyricsView
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.AppleMusicQueueView
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.AppleMusicTypography
|
||||
import com.maxrave.simpmusic.ui.icon.Forward5
|
||||
import com.maxrave.simpmusic.ui.icon.Fullscreen
|
||||
import com.maxrave.simpmusic.ui.icon.Replay5
|
||||
import com.maxrave.simpmusic.ui.icon.SimpIcons
|
||||
import com.maxrave.simpmusic.ui.icon.Subtitles
|
||||
import com.maxrave.simpmusic.ui.icon.SubtitlesOff
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.AppleMusicBottomCluster
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.AppleMusicHeaderActions
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.AppleMusicLyricStrip
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.AppleMusicLyricsView
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.AppleMusicMeshBackdrop
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.AppleMusicOutputSheet
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.AppleMusicQueueView
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.AppleMusicTypography
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.AppleMusicView
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.appleMusicFadeBetween
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.appleMusicGradientColorAt
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.appleMusicVerticalFadeEdges
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.displayName
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.rememberAppleMusicMesh
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.applemusic.rememberAppleMusicTypography
|
||||
import com.maxrave.simpmusic.ui.theme.seed
|
||||
import com.maxrave.simpmusic.ui.theme.typo
|
||||
import com.maxrave.simpmusic.viewModel.NowPlayingScreenData
|
||||
import com.maxrave.simpmusic.viewModel.SharedViewModel
|
||||
import com.maxrave.simpmusic.viewModel.UIEvent
|
||||
import kotlinx.coroutines.delay
|
||||
import org.koin.compose.koinInject
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* The Apple Music Now Playing style: a style-internal dock (Lyrics · Cast · Queue) swaps the
|
||||
* BODY between three full-screen views instead of scrolling a single column, à la
|
||||
* [NowPlayingContentSpotify]/[NowPlayingContentM3Expressive]. The page background is the
|
||||
* artwork's own tint (a vertical gradient), not a Material scheme — see the color-system note on
|
||||
* [AppleMusicMainView] for the canvas/video exception.
|
||||
* The Apple Music Now Playing style: a style-internal dock (Lyrics · [Output | Listen Together] ·
|
||||
* Queue) swaps the BODY between three full-screen views instead of scrolling a single column, à la
|
||||
* [NowPlayingContentSpotify]/[NowPlayingContentM3Expressive]. MAIN stands on a mesh made of the
|
||||
* artwork's own colours; LYRICS and QUEUE on the frosted cover art — see [AppleMusicMainView].
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
|
|
@ -162,10 +171,13 @@ fun NowPlayingContentAppleMusic(
|
|||
val activePillContainer = remember(seedColor) { lerp(seedColor, Color.White, 0.75f) }
|
||||
val activePillContent = remember(seedColor) { lerp(seedColor, Color.Black, 0.6f) }
|
||||
|
||||
// Only a Spotify canvas takes the whole page now; Apple Music's animated artwork plays in the
|
||||
// artwork's own frame (see AppleMusicMainView), so it neither blacks the page out nor hides the
|
||||
// controls.
|
||||
val fullscreenCanvas = state.screenData.canvasData?.let { !it.isAnimatedArtwork() } == true
|
||||
val showCanvasBackdrop =
|
||||
viewState == AppleMusicView.MAIN &&
|
||||
(state.screenData.canvasData != null || (state.screenData.isVideo && state.shouldShowVideo))
|
||||
val isVideoBackdropTop = showCanvasBackdrop && state.screenData.canvasData == null
|
||||
(fullscreenCanvas || (state.screenData.canvasData == null && state.screenData.isVideo && state.shouldShowVideo))
|
||||
|
||||
val deviceVolumeController = rememberDeviceVolumeController()
|
||||
val typography = rememberAppleMusicTypography()
|
||||
|
|
@ -177,11 +189,21 @@ fun NowPlayingContentAppleMusic(
|
|||
// is still on screen, which is the flicker when leaving MAIN for Queue/Lyrics and again on the
|
||||
// way back. Fading the black layer on the SAME 300ms curve keeps the two in step.
|
||||
val canvasBackdropAlpha by animateFloatAsState(
|
||||
targetValue = if (showCanvasBackdrop && !isVideoBackdropTop) 1f else 0f,
|
||||
targetValue = if (showCanvasBackdrop && fullscreenCanvas) 1f else 0f,
|
||||
animationSpec = tween(300),
|
||||
label = "appleMusicCanvasBackdrop",
|
||||
)
|
||||
|
||||
// Where the sound leaves by. The player lists its own outputs; while casting, the receiver is
|
||||
// the answer instead. Named by the same rule the output sheet uses, so the caption and the
|
||||
// ticked row can never disagree.
|
||||
val player = koinInject<MediaPlayerHandler>().player
|
||||
val audioOutputs by player.audioOutputs.collectAsStateWithLifecycle()
|
||||
val activeOutputName = audioOutputs.firstOrNull { it.isActive }?.displayName()
|
||||
val outputName = if (state.castState.isRemote) state.castState.deviceName else activeOutputName
|
||||
var showOutputSheet by rememberSaveable { mutableStateOf(false) }
|
||||
val openOutput = { showOutputSheet = true }
|
||||
|
||||
// Backdrop source for the Desktop dismiss button below. The glass layers MUST be a sibling of
|
||||
// the button, never its parent: nesting the button inside the source is the render-feedback
|
||||
// loop that crashes the RuntimeShader.
|
||||
|
|
@ -210,6 +232,8 @@ fun NowPlayingContentAppleMusic(
|
|||
activePillContainer = activePillContainer,
|
||||
activePillContent = activePillContent,
|
||||
deviceVolumeController = deviceVolumeController,
|
||||
outputName = outputName,
|
||||
onOpenOutput = openOutput,
|
||||
)
|
||||
|
||||
AppleMusicView.LYRICS ->
|
||||
|
|
@ -222,6 +246,8 @@ fun NowPlayingContentAppleMusic(
|
|||
activePillContainer = activePillContainer,
|
||||
activePillContent = activePillContent,
|
||||
deviceVolumeController = deviceVolumeController,
|
||||
outputName = outputName,
|
||||
onOpenOutput = openOutput,
|
||||
)
|
||||
|
||||
AppleMusicView.QUEUE ->
|
||||
|
|
@ -234,6 +260,8 @@ fun NowPlayingContentAppleMusic(
|
|||
activePillContainer = activePillContainer,
|
||||
activePillContent = activePillContent,
|
||||
deviceVolumeController = deviceVolumeController,
|
||||
outputName = outputName,
|
||||
onOpenOutput = openOutput,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -290,20 +318,38 @@ fun NowPlayingContentAppleMusic(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showOutputSheet) {
|
||||
AppleMusicOutputSheet(
|
||||
outputs = audioOutputs,
|
||||
castState = state.castState,
|
||||
deviceVolumeController = deviceVolumeController,
|
||||
onSelectOutput = { output -> player.selectAudioOutput(output.id) },
|
||||
onRefresh = { player.refreshAudioOutputs() },
|
||||
onDismiss = { showOutputSheet = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apple Music's animated covers arrive as HLS (.m3u8) from Apple's video CDN; Spotify's canvases
|
||||
// are MP4. Only the former is album art, shaped like album art (3:4 or 1:1), and so plays in the
|
||||
// artwork's own frame instead of filling the page.
|
||||
private fun NowPlayingScreenData.CanvasData.isAnimatedArtwork(): Boolean = url.contains(".m3u8")
|
||||
|
||||
/**
|
||||
* MAIN: full-bleed artwork pager (top ~60%, fading into the page gradient) with the shell's
|
||||
* artwork-pager sync, a title row, and the shared bottom cluster.
|
||||
* MAIN: the artwork edge to edge at the top — uncropped, at its own proportions — dissolving into
|
||||
* a mesh of its own colours, with the title row, the current lyric and the shared cluster hung
|
||||
* from the bottom of the page.
|
||||
*
|
||||
* Canvas/video mode (design decision A — full screen): when [NowPlayingScreenData.canvasData] is
|
||||
* set or the track is a shown video, the CURRENT page's canvas/video fills the entire pager
|
||||
* (bottom layer, page background goes black) and this same title-row-plus-cluster layout floats
|
||||
* on top with a scrim, auto-hiding via [NowPlayingContentState.showControlLayout] exactly like
|
||||
* [NowPlayingContentM3Expressive] does for its info block. A tap re-shows hidden controls; a tap
|
||||
* on an already-shown VIDEO (not canvas) enters the existing fullscreen video route instead of
|
||||
* rebuilding Classic's ±5s/subtitle overlay here.
|
||||
* The artwork is square for a still cover: a full-width square, with everything else below it.
|
||||
* Apple Music's animated artwork (3:4 or 1:1) plays in a frame of its own shape, top-aligned, and
|
||||
* keeps the controls exactly where the still cover put them — they must not jump when the clip
|
||||
* arrives a few seconds into the track — so a tall clip's lower part fades out behind the title.
|
||||
*
|
||||
* A Spotify canvas (9:16, made to fill a phone) still takes the whole page: the controls float on
|
||||
* top of it with a scrim and auto-hide via [NowPlayingContentState.showControlLayout]. A tap re-shows
|
||||
* hidden controls; a tap on an already-shown VIDEO (not canvas) enters the fullscreen video route.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
|
|
@ -317,19 +363,23 @@ private fun AppleMusicMainView(
|
|||
activePillContainer: Color,
|
||||
activePillContent: Color,
|
||||
deviceVolumeController: DeviceVolumeController?,
|
||||
outputName: String?,
|
||||
onOpenOutput: () -> Unit,
|
||||
) {
|
||||
val screenInfo = getScreenSizeInfo()
|
||||
val localDensity = LocalDensity.current
|
||||
val isRepeatOne = state.controllerState.repeatState is RepeatState.One
|
||||
|
||||
val showCanvasBackdrop =
|
||||
state.screenData.canvasData != null || (state.screenData.isVideo && state.shouldShowVideo)
|
||||
val isVideoBackdrop = showCanvasBackdrop && state.screenData.canvasData == null
|
||||
val canvasData = state.screenData.canvasData
|
||||
val animatedArtwork = canvasData?.takeIf { it.isAnimatedArtwork() }
|
||||
val fullscreenCanvas = canvasData != null && animatedArtwork == null
|
||||
val isVideoBackdrop = canvasData == null && state.screenData.isVideo && state.shouldShowVideo
|
||||
|
||||
// Same fade/half-blended-frame fix M3E uses: fast fade-in, relaxed fade-out.
|
||||
// Same fade/half-blended-frame fix M3E uses: fast fade-in, relaxed fade-out. Only a fullscreen
|
||||
// canvas ever hides the controls — every other artwork sits above them, not behind them, even
|
||||
// though the shell's auto-hide fires for any canvas.
|
||||
val controlsAlpha by animateFloatAsState(
|
||||
targetValue = if (state.showControlLayout) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = if (state.showControlLayout) 180 else 500, easing = LinearEasing),
|
||||
targetValue = if (!fullscreenCanvas || state.showControlLayout) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = if (!fullscreenCanvas || state.showControlLayout) 180 else 500, easing = LinearEasing),
|
||||
label = "appleMusicControlsAlpha",
|
||||
)
|
||||
|
||||
|
|
@ -344,15 +394,47 @@ private fun AppleMusicMainView(
|
|||
}
|
||||
}
|
||||
|
||||
// The artwork zone is whatever's left above the measured title-row+cluster block, so the
|
||||
// whole MAIN view always totals exactly one screen height — no scroll, per spec.
|
||||
// remember, not rememberSaveable: a measured height must not survive a rotation, or a portrait
|
||||
// cluster height is paired with a landscape screen height for a frame. Seeded near its real
|
||||
// value so the FIRST frame doesn't draw a full-screen artwork that then snaps up.
|
||||
var bottomContentHeightDp by remember { mutableIntStateOf(330) }
|
||||
val artworkZoneHeightDp = (screenInfo.hDP - bottomContentHeightDp).coerceAtLeast(200)
|
||||
// Height of the controls block, for fitting a video above it and sizing the canvas idle overlay.
|
||||
// remember, not rememberSaveable: a measured height must not survive a rotation. Seeded near its
|
||||
// real value so the first frame does not lay a video out against a wrong one.
|
||||
var bottomContentHeightDp by remember { mutableIntStateOf(430) }
|
||||
|
||||
// What the page's mesh is read from: the cover, or the animated artwork's still once it is up —
|
||||
// the page should continue whatever is actually on screen above it.
|
||||
var coverBitmap by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||
var clipStill by remember(animatedArtwork?.url) { mutableStateOf<ImageBitmap?>(null) }
|
||||
var clipFrameHeight by remember(animatedArtwork?.url) { mutableStateOf(0.dp) }
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
// A full-width square: the cover at its own proportions, nothing cut off either side. Capped
|
||||
// for a landscape window, where a full-width square would be taller than the page.
|
||||
val artworkHeight = minOf(maxWidth, maxHeight * ARTWORK_MAX_HEIGHT_FRACTION)
|
||||
val clipMaxHeight = maxHeight * ANIMATED_ARTWORK_MAX_HEIGHT_FRACTION
|
||||
val artworkFade = artworkHeight * ARTWORK_FADE_FRACTION
|
||||
val showsClip = animatedArtwork != null && clipStill != null
|
||||
|
||||
// The page itself. A video keeps the frosted cover behind it and a fullscreen canvas the black
|
||||
// page, as before; everything else stands on the mesh, hung from where the artwork ends.
|
||||
val meshPage = !fullscreenCanvas && !isVideoBackdrop
|
||||
val mesh =
|
||||
if (meshPage) {
|
||||
rememberAppleMusicMesh(
|
||||
artwork = if (showsClip) clipStill else coverBitmap,
|
||||
seed = (animatedArtwork?.url ?: state.screenData.thumbnailURL).hashCode(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val meshSeam = if (showsClip) clipFrameHeight else artworkHeight
|
||||
if (meshPage) AppleMusicMeshBackdrop(mesh = mesh, seam = meshSeam)
|
||||
|
||||
// The band under the status bar and the grabber, where a bright or busy sleeve drowns their
|
||||
// white glyphs: blurred (TopBandBlur, inside each page so it moves with it) and darkened
|
||||
// (the wash below). Android only — Desktop has neither, and its dismiss button is glass.
|
||||
val statusBarHeight = with(localDensity) { WindowInsets.statusBars.getTop(localDensity).toDp() }
|
||||
val topBand =
|
||||
if (meshPage && getPlatform() == Platform.Android) statusBarHeight + TOP_BAND_STRIP else 0.dp
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
HorizontalPager(
|
||||
state = state.artworkPagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
@ -368,31 +450,62 @@ private fun AppleMusicMainView(
|
|||
actions = actions,
|
||||
localDensity = localDensity,
|
||||
page = page,
|
||||
artworkZoneHeightDp = artworkZoneHeightDp,
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Top scrim, CANVAS ONLY — same rule the flat black backdrop above follows: a canvas
|
||||
// replaces the whole page, so the status bar and grabber would otherwise float on raw
|
||||
// picture; a video letterboxes and the strip around it is still the artwork gradient,
|
||||
// which is exactly what a plain-artwork track's controls already sit on. Painting these
|
||||
// over a video only dimmed the picture — measured on a 480x830 panel, the top 25% of the
|
||||
// 16:9 frame under up to 0.20 black and the bottom 20% under up to 0.22.
|
||||
//
|
||||
// Rides controlsAlpha, like the scrim below it. A scrim is an accessory of the CONTROLS,
|
||||
// not of the canvas: gating it on showCanvasBackdrop alone left both scrims painted at full
|
||||
// strength after the controls auto-hid, so 82% of the screen height stayed covered — 0.55
|
||||
// black down to y=22%, then 0.45 at y=53% ramping to 0.97 at the bottom — with nothing left
|
||||
// underneath them to make readable. That is the whole of "đen thui cả canvas". The idle
|
||||
// overlay that replaces the controls carries its own page-tint scrim, so the bottom stays
|
||||
// readable without these.
|
||||
if (showCanvasBackdrop && !isVideoBackdrop && controlsAlpha > 0f) {
|
||||
// Android plays the animated artwork on a SurfaceView, which no Compose mask or alpha reaches:
|
||||
// masked like the cover, the clip still ended in a hard edge across the page. So the page is
|
||||
// laid back OVER the clip's lower part instead — the same mesh at the same size and seam, so it
|
||||
// matches the page beneath it pixel for pixel — faded in over the stretch the cover dissolves
|
||||
// over, ending at the clip's bottom edge.
|
||||
if (meshPage && animatedArtwork != null && clipFrameHeight > 0.dp) {
|
||||
AppleMusicMeshBackdrop(
|
||||
mesh = mesh,
|
||||
seam = meshSeam,
|
||||
modifier = Modifier.appleMusicFadeBetween(transparentAt = clipFrameHeight - artworkFade, opaqueAt = clipFrameHeight),
|
||||
)
|
||||
}
|
||||
|
||||
// The wash under the status bar: as dark as the sleeve's own top band is bright, BitChord's
|
||||
// 16%–65% (topBandScrimAlpha), so a dark cover is left almost untouched and a bright one
|
||||
// still carries white icons.
|
||||
if (topBand > 0.dp) {
|
||||
val topWashAlpha by animateFloatAsState(
|
||||
targetValue = TOP_WASH_MIN_ALPHA + (TOP_WASH_MAX_ALPHA - TOP_WASH_MIN_ALPHA) * (mesh?.topLuminance ?: 1f),
|
||||
animationSpec = tween(TOP_WASH_FADE_MS),
|
||||
label = "appleMusicTopWash",
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(topBand)
|
||||
.background(topWashBrush(topWashAlpha)),
|
||||
)
|
||||
}
|
||||
|
||||
// Top scrim, fullscreen CANVAS ONLY: a canvas replaces the whole page, so the status bar and
|
||||
// grabber would otherwise float on raw picture; a video letterboxes and the strip around it
|
||||
// is still the artwork gradient. Rides controlsAlpha — a scrim is an accessory of the
|
||||
// controls, and left painted after they auto-hid it covered most of the canvas for nothing.
|
||||
if (fullscreenCanvas && controlsAlpha > 0f) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -413,15 +526,18 @@ private fun AppleMusicMainView(
|
|||
// while a canvas plays — solid black at the very bottom, fading out upward.
|
||||
// Height is a FRACTION of this Box, never a dp computed from screenInfo: a zero/stale
|
||||
// hDP silently collapsed this scrim to nothing, which is why it kept "not existing".
|
||||
// Canvas only, for the reason spelled out on the top scrim.
|
||||
if (showCanvasBackdrop && !isVideoBackdrop && controlsAlpha > 0f) {
|
||||
if (fullscreenCanvas && controlsAlpha > 0f) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.alpha(controlsAlpha)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.6f)
|
||||
// Tall enough that the controls fill its lower 60%, so the title always sits
|
||||
// at the same depth in it. A fixed 60% of the page left the title in the
|
||||
// faint top of the ramp once the lyric strip and the action row made the
|
||||
// controls taller.
|
||||
.fillMaxHeight(((bottomContentHeightDp.dp / 0.6f) / maxHeight.coerceAtLeast(1.dp)).coerceIn(0.6f, 1f))
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
// Ramps harder early: at the old stops the TITLE row sat at only
|
||||
|
|
@ -444,6 +560,8 @@ private fun AppleMusicMainView(
|
|||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
// Hung from the bottom of the page. Whatever height is left above it and below the
|
||||
// artwork is the mesh continuing the artwork's own colours.
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -453,9 +571,13 @@ private fun AppleMusicMainView(
|
|||
with(localDensity) { coords.size.height.toDp().value.toInt() }
|
||||
},
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
AppleMusicMainTitleRow(state = state, actions = actions, typography = typography)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
AppleMusicLyricStrip(
|
||||
state = state,
|
||||
typography = typography,
|
||||
onClick = { onSelectView(AppleMusicView.LYRICS) },
|
||||
)
|
||||
AppleMusicBottomCluster(
|
||||
state = state,
|
||||
actions = actions,
|
||||
|
|
@ -465,11 +587,15 @@ private fun AppleMusicMainView(
|
|||
activePillContainer = activePillContainer,
|
||||
activePillContent = activePillContent,
|
||||
deviceVolumeController = deviceVolumeController,
|
||||
outputName = outputName,
|
||||
onOpenOutput = onOpenOutput,
|
||||
// The lyric sits right on the progress bar: it is read with the bar, not the title.
|
||||
topPadding = 2.dp,
|
||||
)
|
||||
}
|
||||
// Idle overlay — replaces the (now invisible) title row + cluster while canvas/video
|
||||
// controls are auto-hidden, mirroring M3E's canvas-unfocused overlay verbatim.
|
||||
if (showCanvasBackdrop) {
|
||||
// Idle overlay — replaces the (now invisible) title row + cluster while a fullscreen
|
||||
// canvas has auto-hidden them, mirroring M3E's canvas-unfocused overlay verbatim.
|
||||
if (fullscreenCanvas) {
|
||||
AnimatedVisibility(
|
||||
visible = !state.showControlLayout,
|
||||
enter = fadeIn(),
|
||||
|
|
@ -752,11 +878,10 @@ internal fun AppleMusicMainTitleRow(
|
|||
}
|
||||
|
||||
/**
|
||||
* One pager page. Current page: the live artwork stays composed (alpha 0 under canvas/video) so
|
||||
* [NowPlayingContentActions.onArtworkBitmap] keeps feeding the palette that drives the page
|
||||
* background even while a canvas/video is the visible backdrop — otherwise the background would
|
||||
* go stale on the next track if it also opens on a canvas. Adjacent pages: a static thumbnail,
|
||||
* bounded the same way non-canvas artwork is.
|
||||
* One pager page. Current page: the cover stays composed (alpha 0 under a fullscreen canvas/video,
|
||||
* or once an animated artwork has faded in over it) so [NowPlayingContentActions.onArtworkBitmap]
|
||||
* keeps feeding the palette — otherwise the page colours would go stale on the next track if it
|
||||
* also opens on a canvas. Adjacent pages: a static thumbnail in the same full-width square.
|
||||
*/
|
||||
@Composable
|
||||
private fun AppleMusicArtworkPage(
|
||||
|
|
@ -764,40 +889,64 @@ private fun AppleMusicArtworkPage(
|
|||
actions: NowPlayingContentActions,
|
||||
localDensity: Density,
|
||||
page: Int,
|
||||
artworkZoneHeightDp: Int,
|
||||
artworkHeight: Dp,
|
||||
clipMaxHeight: Dp,
|
||||
topBandSolid: Dp,
|
||||
topBand: Dp,
|
||||
bottomContentHeightDp: Int,
|
||||
fullscreenCanvas: Boolean,
|
||||
animatedArtwork: NowPlayingScreenData.CanvasData?,
|
||||
isVideoBackdrop: Boolean,
|
||||
showVideoOverlay: Boolean,
|
||||
onToggleVideoOverlay: () -> Unit,
|
||||
showSubtitle: Boolean,
|
||||
onToggleSubtitle: () -> Unit,
|
||||
onCoverBitmap: (ImageBitmap) -> Unit,
|
||||
onClipStill: (ImageBitmap?, Dp) -> Unit,
|
||||
) {
|
||||
val pageTrack = state.artworkQueue.getOrNull(page)
|
||||
val isCurrentPage = page == state.currentOrderIndex
|
||||
val pageShowsCanvasOrVideo =
|
||||
isCurrentPage && (state.screenData.canvasData != null || (state.screenData.isVideo && state.shouldShowVideo))
|
||||
val pageShowsFullscreen = isCurrentPage && (fullscreenCanvas || isVideoBackdrop)
|
||||
val pageShowsClip = isCurrentPage && animatedArtwork != null
|
||||
// How much of the artwork dissolves into the page: BitChord's 42% of the frame, on a
|
||||
// smoothstep curve (appleMusicVerticalFadeEdges), so there is no line where the fade starts.
|
||||
val artworkFade = artworkHeight * ARTWORK_FADE_FRACTION
|
||||
|
||||
// The animated artwork fades in over the cover once its still is up, rather than cutting in.
|
||||
var clipReady by remember(animatedArtwork?.url) { mutableStateOf(false) }
|
||||
val clipAlpha by animateFloatAsState(
|
||||
targetValue = if (pageShowsClip && clipReady) 1f else 0f,
|
||||
animationSpec = tween(ARTWORK_FADE_IN_MS),
|
||||
label = "appleMusicAnimatedArtworkIn",
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (isCurrentPage) {
|
||||
var artworkUrl by remember(state.screenData.thumbnailURL) { mutableStateOf(state.screenData.thumbnailURL) }
|
||||
val coverRequest =
|
||||
ImageRequest
|
||||
.Builder(LocalPlatformContext.current)
|
||||
.data(artworkUrl)
|
||||
.diskCachePolicy(CachePolicy.ENABLED)
|
||||
.diskCacheKey(artworkUrl + "BIGGER")
|
||||
.crossfade(550)
|
||||
.build()
|
||||
val coverAlpha = if (pageShowsFullscreen) 0f else 1f - clipAlpha
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.height(artworkZoneHeightDp.dp),
|
||||
.height(artworkHeight),
|
||||
) {
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalPlatformContext.current)
|
||||
.data(artworkUrl)
|
||||
.diskCachePolicy(CachePolicy.ENABLED)
|
||||
.diskCacheKey(artworkUrl + "BIGGER")
|
||||
.crossfade(550)
|
||||
.build(),
|
||||
model = coverRequest,
|
||||
contentDescription = "",
|
||||
onSuccess = { actions.onArtworkBitmap(it.result.image.toImageBitmap()) },
|
||||
onSuccess = {
|
||||
val bitmap = it.result.image.toImageBitmap()
|
||||
actions.onArtworkBitmap(bitmap)
|
||||
onCoverBitmap(bitmap)
|
||||
},
|
||||
onError = {
|
||||
val fallback = artworkUrl?.replace("maxresdefault", "hqdefault")
|
||||
if (fallback != null && fallback != artworkUrl) artworkUrl = fallback
|
||||
|
|
@ -806,17 +955,31 @@ private fun AppleMusicArtworkPage(
|
|||
placeholder = rememberHolderPainter(),
|
||||
error = rememberHolderPainter(),
|
||||
// The artwork DISSOLVES (alpha mask) instead of being covered by a colour
|
||||
// overlay: that overlay had to land on exactly the page gradient's colour at
|
||||
// that Y, and any drift drew a hard horizontal line across the screen.
|
||||
// Masking lets the real background show through — nothing left to match.
|
||||
// overlay: that overlay had to land on exactly the page's colour at that Y,
|
||||
// and any drift drew a hard horizontal line across the screen. Masking lets
|
||||
// the real background show through — nothing left to match.
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.alpha(if (pageShowsCanvasOrVideo) 0f else 1f)
|
||||
.appleMusicVerticalFadeEdges(topFade = 0.dp, bottomFade = 300.dp),
|
||||
.alpha(coverAlpha)
|
||||
.appleMusicVerticalFadeEdges(topFade = 0.dp, bottomFade = artworkFade),
|
||||
)
|
||||
TopBandBlur(model = coverRequest, solidTo = topBandSolid, fadeTo = topBand, modifier = Modifier.alpha(coverAlpha))
|
||||
}
|
||||
if (pageShowsClip && animatedArtwork != null) {
|
||||
AnimatedArtworkFrame(
|
||||
canvas = animatedArtwork,
|
||||
heightLimit = clipMaxHeight,
|
||||
topBandSolid = topBandSolid,
|
||||
topBand = topBand,
|
||||
alpha = clipAlpha,
|
||||
onStill = { still, height ->
|
||||
clipReady = true
|
||||
onClipStill(still, height)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (pageShowsCanvasOrVideo) {
|
||||
if (pageShowsFullscreen) {
|
||||
if (isVideoBackdrop) {
|
||||
// Centre the video in the region ABOVE the controls (top → cluster), not in
|
||||
// the whole screen: screen-centred, half of a 16:9 video sat behind the
|
||||
|
|
@ -833,10 +996,10 @@ private fun AppleMusicArtworkPage(
|
|||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
// The page's own height minus the controls, not artworkZoneHeightDp:
|
||||
// that one is measured against the window, and the Desktop side panel
|
||||
// is shorter than the window by its shell padding, which pushed a tall
|
||||
// frame down into the gap above the title.
|
||||
// The page's own height minus the controls, not a window-based
|
||||
// measure: the Desktop side panel is shorter than the window by its
|
||||
// shell padding, which pushed a tall frame down into the gap above
|
||||
// the title.
|
||||
.fillMaxSize()
|
||||
.padding(bottom = bottomContentHeightDp.dp)
|
||||
.clickable(
|
||||
|
|
@ -864,84 +1027,84 @@ private fun AppleMusicArtworkPage(
|
|||
// like a card; wide ones run edge to edge and stay square.
|
||||
.then(if (ratio < 1f) Modifier.clip(RoundedCornerShape(12.dp)) else Modifier),
|
||||
) {
|
||||
MediaPlayerViewWithSubtitle(
|
||||
playerName = MAIN_PLAYER,
|
||||
// fillMaxWidth, never fillMaxSize: a free height lets the surface's
|
||||
// own aspect ratio apply, which is what stops it being stretched.
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.Center),
|
||||
shouldShowSubtitle = showSubtitle,
|
||||
shouldPip = false,
|
||||
shouldScaleDownSubtitle = true,
|
||||
timelineState = state.timelineState,
|
||||
lyricsData = state.screenData.lyricsData?.lyrics,
|
||||
translatedLyricsData = state.screenData.lyricsData?.translatedLyrics?.first,
|
||||
isInPipMode = state.isInPipMode,
|
||||
mainTextStyle = typo().bodyLarge,
|
||||
translatedTextStyle = typo().bodyMedium,
|
||||
)
|
||||
MediaPlayerViewWithSubtitle(
|
||||
playerName = MAIN_PLAYER,
|
||||
// fillMaxWidth, never fillMaxSize: a free height lets the surface's
|
||||
// own aspect ratio apply, which is what stops it being stretched.
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.Center),
|
||||
shouldShowSubtitle = showSubtitle,
|
||||
shouldPip = false,
|
||||
shouldScaleDownSubtitle = true,
|
||||
timelineState = state.timelineState,
|
||||
lyricsData = state.screenData.lyricsData?.lyrics,
|
||||
translatedLyricsData = state.screenData.lyricsData?.translatedLyrics?.first,
|
||||
isInPipMode = state.isInPipMode,
|
||||
mainTextStyle = typo().bodyLarge,
|
||||
translatedTextStyle = typo().bodyMedium,
|
||||
)
|
||||
|
||||
// Classic/M3E's over-video controls, ported: fullscreen, ±5s, subtitles.
|
||||
// A tap on the video toggles them; they auto-hide after 3s.
|
||||
// Rendered INSIDE the video zone and drawn after the tap-catcher below,
|
||||
// so its buttons are the topmost target — otherwise the catcher swallows
|
||||
// every tap and the buttons look dead.
|
||||
Crossfade(targetState = showVideoOverlay, label = "appleMusicVideoOverlay") { shown ->
|
||||
if (shown) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.28f)),
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { actions.onEnterFullscreenVideo() },
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
) {
|
||||
Icon(imageVector = SimpIcons.Fullscreen, contentDescription = "", tint = Color.White)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.Center).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
// Classic/M3E's over-video controls, ported: fullscreen, ±5s, subtitles.
|
||||
// A tap on the video toggles them; they auto-hide after 3s.
|
||||
// Rendered INSIDE the video zone and drawn after the tap-catcher below,
|
||||
// so its buttons are the topmost target — otherwise the catcher swallows
|
||||
// every tap and the buttons look dead.
|
||||
Crossfade(targetState = showVideoOverlay, label = "appleMusicVideoOverlay") { shown ->
|
||||
if (shown) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.28f)),
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { actions.onUIEvent(UIEvent.Backward) },
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||
onClick = { actions.onEnterFullscreenVideo() },
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = SimpIcons.Replay5,
|
||||
contentDescription = "",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(36.dp).alpha(0.8f),
|
||||
)
|
||||
Icon(imageVector = SimpIcons.Fullscreen, contentDescription = "", tint = Color.White)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { actions.onUIEvent(UIEvent.Forward) },
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.Center).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
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) {
|
||||
IconButton(
|
||||
onClick = { onToggleSubtitle() },
|
||||
modifier = Modifier.align(Alignment.BottomEnd),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (showSubtitle) SimpIcons.SubtitlesOff else SimpIcons.Subtitles,
|
||||
contentDescription = "",
|
||||
tint = Color.White,
|
||||
)
|
||||
if (state.screenData.lyricsData != null) {
|
||||
IconButton(
|
||||
onClick = { onToggleSubtitle() },
|
||||
modifier = Modifier.align(Alignment.BottomEnd),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (showSubtitle) SimpIcons.SubtitlesOff else SimpIcons.Subtitles,
|
||||
contentDescription = "",
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Crossfade(targetState = state.screenData.canvasData?.isVideo, label = "appleMusicCanvasKind") { isVideo ->
|
||||
|
|
@ -1000,33 +1163,152 @@ private fun AppleMusicArtworkPage(
|
|||
}
|
||||
} else if (pageTrack != null) {
|
||||
val staticThumb = pageTrack.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
val thumbRequest =
|
||||
ImageRequest
|
||||
.Builder(LocalPlatformContext.current)
|
||||
.data(staticThumb)
|
||||
.diskCachePolicy(CachePolicy.ENABLED)
|
||||
.diskCacheKey(staticThumb)
|
||||
.crossfade(300)
|
||||
.build()
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.height(artworkZoneHeightDp.dp),
|
||||
.height(artworkHeight),
|
||||
) {
|
||||
AsyncImage(
|
||||
model =
|
||||
ImageRequest
|
||||
.Builder(LocalPlatformContext.current)
|
||||
.data(staticThumb)
|
||||
.diskCachePolicy(CachePolicy.ENABLED)
|
||||
.diskCacheKey(staticThumb)
|
||||
.crossfade(300)
|
||||
.build(),
|
||||
model = thumbRequest,
|
||||
contentDescription = pageTrack.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
placeholder = rememberHolderPainter(),
|
||||
error = rememberHolderPainter(),
|
||||
modifier = Modifier.fillMaxSize().appleMusicVerticalFadeEdges(topFade = 0.dp, bottomFade = 300.dp),
|
||||
modifier = Modifier.fillMaxSize().appleMusicVerticalFadeEdges(topFade = 0.dp, bottomFade = artworkFade),
|
||||
)
|
||||
// Blurred like the current page's, or its top band would turn sharp mid-swipe.
|
||||
TopBandBlur(model = thumbRequest, solidTo = topBandSolid, fadeTo = topBand)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apple Music's animated artwork in a frame of its own shape, full width and top-aligned, so none
|
||||
* of it is cropped. The clip's still sits under it: it sizes the frame (its proportions ARE the
|
||||
* clip's) and shows while the clip loads, so the frame never opens as a black box. [onStill] hands
|
||||
* the still and the frame's height back for the page's mesh, and for the copy of the page that
|
||||
* AppleMusicMainView lays over the frame's lower part — the frame itself carries no fade mask,
|
||||
* because a mask never reaches the video surface.
|
||||
*/
|
||||
@Composable
|
||||
private fun BoxScope.AnimatedArtworkFrame(
|
||||
canvas: NowPlayingScreenData.CanvasData,
|
||||
heightLimit: Dp,
|
||||
topBandSolid: Dp,
|
||||
topBand: Dp,
|
||||
alpha: Float,
|
||||
onStill: (ImageBitmap?, Dp) -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth()) {
|
||||
var aspect by remember(canvas.url) { mutableFloatStateOf(DEFAULT_ANIMATED_ARTWORK_ASPECT) }
|
||||
val frameWidth = maxWidth
|
||||
val frameHeight = (frameWidth / aspect).coerceAtMost(heightLimit)
|
||||
// No still to wait for (an older cached row): show the frame straight away.
|
||||
LaunchedEffect(canvas.url, canvas.thumbUrl) {
|
||||
if (canvas.thumbUrl == null) onStill(null, frameHeight)
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(frameHeight)
|
||||
.alpha(alpha),
|
||||
) {
|
||||
val stillRequest =
|
||||
canvas.thumbUrl?.let { still ->
|
||||
ImageRequest
|
||||
.Builder(LocalPlatformContext.current)
|
||||
.data(still)
|
||||
.diskCachePolicy(CachePolicy.ENABLED)
|
||||
.diskCacheKey(still)
|
||||
.build()
|
||||
}
|
||||
stillRequest?.let { request ->
|
||||
AsyncImage(
|
||||
model = request,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
onSuccess = {
|
||||
val image = it.result.image
|
||||
if (image.width > 0 && image.height > 0) aspect = image.width.toFloat() / image.height
|
||||
onStill(image.toImageBitmap(), (frameWidth / aspect).coerceAtMost(heightLimit))
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
// The frame already has the clip's own shape, so "crop to bounds" crops nothing; it is
|
||||
// the path that sizes the surface from Media3's own presentation state (see the canvas
|
||||
// note in AppleMusicArtworkPage) and keeps a shutter up until the first frame is ready.
|
||||
MediaPlayerView(
|
||||
url = canvas.url,
|
||||
cropToBounds = true,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
// Nothing can blur the video surface itself, so its band is the still's, blurred: at 32dp
|
||||
// it is a wash of the clip's colours, which barely move in the strip under the status bar.
|
||||
stillRequest?.let { TopBandBlur(model = it, solidTo = topBandSolid, fadeTo = topBand) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The band under the status bar and the grabber, blurred: a sharp, busy sleeve there fights their
|
||||
* glyphs. A Modifier.blur copy of the picture, solid down to [solidTo] and gone by [fadeTo] — the
|
||||
* artist page's technique. Below Android 12 blur is a no-op, the copy is then pixel-identical to
|
||||
* the picture under it, and only the page's dark wash remains. Nothing when [fadeTo] is zero.
|
||||
*/
|
||||
@Composable
|
||||
private fun TopBandBlur(
|
||||
model: Any?,
|
||||
solidTo: Dp,
|
||||
fadeTo: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (fadeTo <= 0.dp) return
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.appleMusicFadeBetween(transparentAt = fadeTo, opaqueAt = solidTo)
|
||||
.blur(TOP_BAND_BLUR),
|
||||
)
|
||||
}
|
||||
|
||||
// BitChord's status-bar gradient (NowPlayingScreen.kt): alpha × (1 − p)^1.5, dark right at the top
|
||||
// and falling off fast. The same curve at 24 stops rather than its 8, so no step shows.
|
||||
private fun topWashBrush(alpha: Float): Brush =
|
||||
Brush.verticalGradient(
|
||||
colorStops =
|
||||
Array(TOP_WASH_STEPS + 1) { i ->
|
||||
val p = i / TOP_WASH_STEPS.toFloat()
|
||||
p to Color.Black.copy(alpha = alpha * (1f - p).pow(1.5f))
|
||||
},
|
||||
)
|
||||
|
||||
// The top band's length below the status bar (BitChord's DISMISS_STRIP_HEIGHT), the wash's range
|
||||
// (BitChord's topBandScrimAlpha, 16% on a black sleeve to 65% on a white one), its stops, its
|
||||
// change on a skip (the mesh's own crossfade), and the band's blur (the artist page's).
|
||||
private val TOP_BAND_STRIP = 32.dp
|
||||
private const val TOP_WASH_STEPS = 24
|
||||
private const val TOP_WASH_MIN_ALPHA = 0.16f
|
||||
private const val TOP_WASH_MAX_ALPHA = 0.65f
|
||||
private const val TOP_WASH_FADE_MS = 900
|
||||
private val TOP_BAND_BLUR = 32.dp
|
||||
|
||||
// Radius of the frosted cover art behind the page. Large enough that no detail of the artwork
|
||||
// survives as a shape — what is left is its colour and its broad light and dark areas, which is
|
||||
// precisely what Apple's background is.
|
||||
|
|
@ -1048,6 +1330,24 @@ private val VIDEO_FRAME_TOP_GAP = 16.dp
|
|||
// towards the bottom so the transport stays readable; not so much that it hides the art again.
|
||||
private const val BACKDROP_TINT_ALPHA = 0.62f
|
||||
|
||||
// The bottom share of the artwork that dissolves into the page — BitChord's HERO_FADE_FRACTION.
|
||||
private const val ARTWORK_FADE_FRACTION = 0.42f
|
||||
|
||||
// A full-width square cover fits a portrait page with room to spare; only a landscape window is
|
||||
// short enough for this cap to bite.
|
||||
private const val ARTWORK_MAX_HEIGHT_FRACTION = 0.62f
|
||||
|
||||
// A 3:4 animated artwork runs under the title row by design; this only stops a landscape window
|
||||
// from turning it into the whole page.
|
||||
private const val ANIMATED_ARTWORK_MAX_HEIGHT_FRACTION = 0.75f
|
||||
|
||||
// Apple's tall motion artwork (motionDetailTall), which the artwork source prefers — the frame's
|
||||
// shape until the clip's still says otherwise.
|
||||
private const val DEFAULT_ANIMATED_ARTWORK_ASPECT = 3f / 4f
|
||||
|
||||
// How long an animated artwork takes to replace the cover once it is ready — BitChord's hero fade.
|
||||
private const val ARTWORK_FADE_IN_MS = 420
|
||||
|
||||
// How long the LYRICS tab waits for a track's lyrics before deciding the track has none. Long
|
||||
// enough to cover a normal fetch on a normal connection, short enough that a song with no lyrics
|
||||
// does not leave the user staring at an empty page. Not a fixed budget for the request itself:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import androidx.compose.runtime.Stable
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.maxrave.domain.data.entities.NewFormatEntity
|
||||
import com.maxrave.domain.data.model.browse.album.Track
|
||||
import com.maxrave.domain.data.model.streams.TimeLine
|
||||
import com.maxrave.domain.data.player.GenericCastState
|
||||
|
|
@ -17,6 +18,7 @@ import com.maxrave.simpmusic.viewModel.LyricsProvider
|
|||
import com.maxrave.simpmusic.viewModel.NowPlayingScreenData
|
||||
import com.maxrave.simpmusic.viewModel.UIEvent
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Whether the lyrics currently on screen can be rated.
|
||||
|
|
@ -58,20 +60,26 @@ internal fun String.stripRichSyncTimestamps(): String =
|
|||
.replace(WHITESPACE_REGEX, " ")
|
||||
.trim()
|
||||
|
||||
// Codec label for the Apple Music style's progress-bar badge. Derived from the stream's
|
||||
// mimeType (e.g. `audio/webm; codecs="opus"`, `audio/mp4; codecs="mp4a.40.2"`) rather than the
|
||||
// itag, which the two YouTube audio families always encode as one of these two codecs. Returns
|
||||
// null for anything else so the badge hides instead of showing a guess.
|
||||
internal fun String?.toAudioCodecLabel(): String? {
|
||||
// Fed NewFormatEntity.codecs — "opus", or "mp4a.40.2" for AAC. The regex that fills that
|
||||
// column falls back to the WHOLE mimeType when it fails to match, so both shapes have to be
|
||||
// recognised here; "aac" covers the Piped path, which reports the codec by name.
|
||||
val codec = this ?: return null
|
||||
return when {
|
||||
codec.contains("opus", ignoreCase = true) -> "OPUS"
|
||||
codec.contains("mp4a", ignoreCase = true) || codec.contains("aac", ignoreCase = true) -> "AAC"
|
||||
else -> null
|
||||
}
|
||||
/**
|
||||
* "154 kbps · 48 kHz" — the Apple Music style's quality line, read off the format YouTube served
|
||||
* for the track now playing: its `bitrate` (the figure the Info sheet prints in bps) and its
|
||||
* `sampleRate`. A figure the format does not carry is dropped rather than guessed, and null comes
|
||||
* back when neither is known, so the line hides instead of showing a placeholder.
|
||||
*/
|
||||
internal fun NewFormatEntity?.toAudioQualityLabel(): String? {
|
||||
val format = this ?: return null
|
||||
val parts =
|
||||
buildList {
|
||||
format.bitrate?.takeIf { it > 0 }?.let { add("${(it / 1000.0).roundToInt()} kbps") }
|
||||
format.sampleRate?.takeIf { it > 0 }?.let { add(it.toKhzLabel()) }
|
||||
}
|
||||
return parts.joinToString(" · ").takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
// 48000 → "48 kHz", 44100 → "44.1 kHz": one decimal, and none when it would be ".0".
|
||||
private fun Int.toKhzLabel(): String {
|
||||
val tenths = (this + 50) / 100
|
||||
return if (tenths % 10 == 0) "${tenths / 10} kHz" else "${tenths / 10}.${tenths % 10} kHz"
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -107,8 +115,13 @@ class NowPlayingContentState(
|
|||
val mainScrollState: ScrollState,
|
||||
val isExpanded: Boolean,
|
||||
val dismissIcon: ImageVector,
|
||||
/** Current track's audio codec ("OPUS"/"AAC"), or null while unknown — see [toAudioCodecLabel]. */
|
||||
val audioCodecLabel: String? = null,
|
||||
/** "154 kbps · 48 kHz" for the stream now playing, or null while unknown — see [toAudioQualityLabel]. */
|
||||
val audioQualityLabel: String? = null,
|
||||
/**
|
||||
* The user's lyrics delay, applied at read time. [currentLyricLineIndex] already has it baked in;
|
||||
* this is for anything that times WITHIN a line (the Apple Music lyric strip's word sweep).
|
||||
*/
|
||||
val lyricsOffsetMs: Long = 0L,
|
||||
/**
|
||||
* Width / height of the video now playing, 16:9 until the player knows it. Every style sizes
|
||||
* its video frame from this one value, so a frame and the spacer that measures it cannot drift.
|
||||
|
|
@ -129,6 +142,7 @@ class NowPlayingContentActions(
|
|||
val onSliderChangeFinished: () -> Unit,
|
||||
val onToggleControls: () -> Unit,
|
||||
val onNavigateToArtist: () -> Unit,
|
||||
val onOpenListenTogether: () -> Unit,
|
||||
val onAddToYouTubeLiked: () -> Unit,
|
||||
val onShowMoreSheet: () -> Unit,
|
||||
val onShowQueue: () -> Unit,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,317 @@
|
|||
package com.maxrave.simpmusic.ui.screen.player.content.applemusic
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.maxrave.domain.data.model.metadata.Line
|
||||
import com.maxrave.simpmusic.extension.ParsedRichSyncLine
|
||||
import com.maxrave.simpmusic.extension.parseRichSyncWords
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.NowPlayingContentState
|
||||
import com.maxrave.simpmusic.ui.screen.player.content.stripRichSyncTimestamps
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* The line being sung, directly over the progress bar — BitChord's strip. Tapping it opens the
|
||||
* Lyrics tab.
|
||||
*
|
||||
* Word-synced lyrics are lit as they are sung: the line is drawn twice, dim and bright, and the
|
||||
* bright copy is clipped to where the singer has got to. Clipping in the draw phase, off a playhead
|
||||
* carried forward on the frame clock, is what lets the light travel smoothly INSIDE a word — coloured
|
||||
* spans can only change a whole word at a time. Line-synced lyrics light the whole line at once.
|
||||
*
|
||||
* Before the first line starts the slot holds three dots rather than nothing, so an intro does not
|
||||
* leave a gap between the title and the bar.
|
||||
*
|
||||
* Always one line tall, empty or not, so the controls below it never move when a line ends or a
|
||||
* track has no synced lyrics.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AppleMusicLyricStrip(
|
||||
state: NowPlayingContentState,
|
||||
typography: AppleMusicTypography,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val lyrics = state.screenData.lyricsData?.lyrics
|
||||
val syncType = lyrics?.syncType
|
||||
val synced = lyrics?.lines != null && syncType != null && syncType != "UNSYNCED"
|
||||
val richSynced = synced && syncType == "RICH_SYNCED"
|
||||
// The index is -1 only until the first line starts: once a line has been reached it is held
|
||||
// until the next one, and the last is held to the end of the track.
|
||||
val line = if (synced) lyrics.lines?.getOrNull(state.currentLyricLineIndex) else null
|
||||
|
||||
val playhead =
|
||||
rememberSweepPlayhead(
|
||||
rawMs = state.timelineState.current - state.lyricsOffsetMs,
|
||||
running = line != null && richSynced && state.controllerState.isPlaying,
|
||||
)
|
||||
|
||||
AnimatedContent(
|
||||
// Keyed on the line itself: the index alone would miss a lyrics swap that lands on the same
|
||||
// index, and the text alone would merge two identical lines sung back to back.
|
||||
targetState =
|
||||
when {
|
||||
line != null -> StripContent.Sung(line)
|
||||
synced -> StripContent.Waiting
|
||||
else -> null
|
||||
},
|
||||
transitionSpec = {
|
||||
// The new line rises into place as the old one rises away, a third of a line each way —
|
||||
// the direction lyrics travel in the Lyrics tab, so the two read as the same motion.
|
||||
val enter =
|
||||
fadeIn(tween(LINE_CHANGE_MS, easing = FastOutSlowInEasing)) +
|
||||
slideInVertically(tween(LINE_CHANGE_MS, easing = FastOutSlowInEasing)) { (it * 0.35f).toInt() }
|
||||
val exit =
|
||||
fadeOut(tween(LINE_CHANGE_MS, easing = FastOutSlowInEasing)) +
|
||||
slideOutVertically(tween(LINE_CHANGE_MS, easing = FastOutSlowInEasing)) { -(it * 0.35f).toInt() }
|
||||
(enter togetherWith exit).using(SizeTransform(clip = false))
|
||||
},
|
||||
label = "appleMusicLyricStrip",
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
enabled = synced,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
),
|
||||
) { shown ->
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp), contentAlignment = Alignment.CenterStart) {
|
||||
// Every branch starts from a one-line Text, which is what keeps the slot the same height.
|
||||
when (shown) {
|
||||
is StripContent.Sung -> {
|
||||
val parsed =
|
||||
remember(shown.line, richSynced) {
|
||||
if (richSynced) parseRichSyncWords(shown.line.words, shown.line.startTimeMs, shown.line.endTimeMs) else null
|
||||
}
|
||||
if (parsed != null) {
|
||||
SweptLine(parsed = parsed, playhead = playhead, typography = typography)
|
||||
} else {
|
||||
StripText(text = shown.line.displayText(), typography = typography)
|
||||
}
|
||||
}
|
||||
|
||||
StripContent.Waiting -> {
|
||||
StripText(text = "", typography = typography)
|
||||
WaitingDots()
|
||||
}
|
||||
|
||||
null -> StripText(text = "", typography = typography)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What the strip is showing: a line being sung, or the wait before the first one.
|
||||
private sealed interface StripContent {
|
||||
data class Sung(
|
||||
val line: Line,
|
||||
) : StripContent
|
||||
|
||||
data object Waiting : StripContent
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StripText(
|
||||
text: String,
|
||||
typography: AppleMusicTypography,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = typography.lyricStrip,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
// Three dots in a row, centred on the line's height by the Box around them.
|
||||
@Composable
|
||||
private fun WaitingDots() {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(WAITING_DOT_GAP)) {
|
||||
repeat(3) {
|
||||
Box(modifier = Modifier.size(WAITING_DOT_SIZE).clip(CircleShape).background(UNSUNG))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SweptLine(
|
||||
parsed: ParsedRichSyncLine,
|
||||
playhead: State<Long>,
|
||||
typography: AppleMusicTypography,
|
||||
) {
|
||||
// The words joined the way the line is shown, with each word's character range kept, so the
|
||||
// playhead can be turned into an x position on the laid-out text.
|
||||
val text = remember(parsed) { parsed.words.joinToString(" ") { it.text } }
|
||||
val ranges =
|
||||
remember(parsed) {
|
||||
var start = 0
|
||||
parsed.words.map { word ->
|
||||
val range = start until start + word.text.length
|
||||
start += word.text.length + 1
|
||||
range
|
||||
}
|
||||
}
|
||||
var layout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
Box {
|
||||
Text(
|
||||
text = text,
|
||||
style = typography.lyricStrip.copy(color = UNSUNG),
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
onTextLayout = { layout = it },
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = typography.lyricStrip,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier.drawWithContent {
|
||||
val measured = layout ?: return@drawWithContent
|
||||
val sweepX = sweepX(parsed, ranges, measured, playhead.value)
|
||||
if (sweepX <= 0f) return@drawWithContent
|
||||
clipRect(right = sweepX) { this@drawWithContent.drawContent() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Where the light has got to: every word already sung, plus the part of the current one its own
|
||||
// duration says has gone by. A word runs until the next one starts; the last runs to the line's end.
|
||||
private fun sweepX(
|
||||
parsed: ParsedRichSyncLine,
|
||||
ranges: List<IntRange>,
|
||||
layout: TextLayoutResult,
|
||||
positionMs: Long,
|
||||
): Float {
|
||||
val words = parsed.words
|
||||
val current = words.indexOfLast { it.startTimeMs <= positionMs }
|
||||
if (current < 0) return 0f
|
||||
val range = ranges[current]
|
||||
val startMs = words[current].startTimeMs
|
||||
val endMs =
|
||||
words.getOrNull(current + 1)?.startTimeMs
|
||||
?: parsed.lineEndTimeMs.takeIf { it != Long.MAX_VALUE && it > startMs }
|
||||
?: (startMs + LAST_WORD_FALLBACK_MS)
|
||||
val progress = ((positionMs - startMs).toFloat() / (endMs - startMs).coerceAtLeast(1L)).coerceIn(0f, 1f)
|
||||
val lastIndex = (layout.layoutInput.text.length).coerceAtLeast(1)
|
||||
val left = layout.getHorizontalPosition(range.first.coerceIn(0, lastIndex), usePrimaryDirection = true)
|
||||
val right = layout.getHorizontalPosition((range.last + 1).coerceIn(0, lastIndex), usePrimaryDirection = true)
|
||||
return left + (right - left) * progress
|
||||
}
|
||||
|
||||
/**
|
||||
* The playhead the sweep is drawn from. Positions arrive every 50 ms, a frame or two late and never
|
||||
* on a frame. Restarting from each one — rememberSmoothPlayhead, which a word-at-a-time wipe can
|
||||
* afford — makes a continuous sweep stall and then jump by that much several times a second. This
|
||||
* one keeps its own time on the frame clock and only leans toward the reports, so it moves at a
|
||||
* steady speed and never steps back. A report further off than [SWEEP_SNAP_MS] is a seek, and it
|
||||
* goes straight there. While [running] is false it simply follows the reports.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberSweepPlayhead(
|
||||
rawMs: Long,
|
||||
running: Boolean,
|
||||
): State<Long> {
|
||||
val playhead = remember { mutableLongStateOf(rawMs) }
|
||||
val latestRaw by rememberUpdatedState(rawMs)
|
||||
LaunchedEffect(running) {
|
||||
if (!running) {
|
||||
snapshotFlow { latestRaw }.collect { playhead.longValue = it }
|
||||
return@LaunchedEffect
|
||||
}
|
||||
var reported = latestRaw
|
||||
var reportedAt = -1L
|
||||
var lastFrame = -1L
|
||||
var position = playhead.longValue.toDouble()
|
||||
while (true) {
|
||||
withFrameNanos { now ->
|
||||
if (reportedAt < 0L || latestRaw != reported) {
|
||||
reported = latestRaw
|
||||
reportedAt = now
|
||||
}
|
||||
// Where the last report puts the song by now. Capped, so a player that has stalled
|
||||
// (no new report while it buffers) cannot drag the light on without it.
|
||||
val target = reported + ((now - reportedAt) / 1_000_000.0).coerceAtMost(SWEEP_MAX_EXTRAPOLATION_MS)
|
||||
val step = if (lastFrame < 0L) 0.0 else (now - lastFrame) / 1_000_000.0
|
||||
lastFrame = now
|
||||
position =
|
||||
if (abs(target - position) > SWEEP_SNAP_MS) {
|
||||
target
|
||||
} else {
|
||||
// Close a share of the gap each frame, in proportion to the frame's length so
|
||||
// 60 Hz and 120 Hz settle alike; max() keeps it from ever running backwards.
|
||||
val advanced = position + step
|
||||
max(position, advanced + (target - advanced) * (step / SWEEP_CATCH_UP_MS).coerceAtMost(1.0))
|
||||
}
|
||||
playhead.longValue = position.roundToLong()
|
||||
}
|
||||
}
|
||||
}
|
||||
return playhead
|
||||
}
|
||||
|
||||
private fun Line.displayText(): String = words.stripRichSyncTimestamps()
|
||||
|
||||
// The unsung part of a word-synced line: present enough to read ahead, clearly not lit yet.
|
||||
private val UNSUNG = Color.White.copy(alpha = 0.45f)
|
||||
|
||||
private val WAITING_DOT_SIZE = 5.dp
|
||||
private val WAITING_DOT_GAP = 4.dp
|
||||
|
||||
// BitChord's line change: long enough to read as travel, short enough that fast lines do not queue.
|
||||
private const val LINE_CHANGE_MS = 340
|
||||
|
||||
// A line's end time is often absent; the last word is then given this long to light.
|
||||
private const val LAST_WORD_FALLBACK_MS = 600L
|
||||
|
||||
// How the sweep's own clock tracks the reported position: the time it takes to close most of a
|
||||
// gap, the largest gap it closes gradually rather than jumping, and how far past the last report
|
||||
// it will run on its own.
|
||||
private const val SWEEP_CATCH_UP_MS = 150.0
|
||||
private const val SWEEP_SNAP_MS = 500.0
|
||||
private const val SWEEP_MAX_EXTRAPOLATION_MS = 250.0
|
||||
|
|
@ -90,6 +90,8 @@ internal fun AppleMusicLyricsView(
|
|||
activePillContainer: Color,
|
||||
activePillContent: Color,
|
||||
deviceVolumeController: DeviceVolumeController?,
|
||||
outputName: String?,
|
||||
onOpenOutput: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
dataStoreManager: DataStoreManager = koinInject(),
|
||||
) {
|
||||
|
|
@ -295,6 +297,8 @@ internal fun AppleMusicLyricsView(
|
|||
activePillContainer = activePillContainer,
|
||||
activePillContent = activePillContent,
|
||||
deviceVolumeController = deviceVolumeController,
|
||||
outputName = outputName,
|
||||
onOpenOutput = onOpenOutput,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,348 @@
|
|||
package com.maxrave.simpmusic.ui.screen.player.content.applemusic
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Paint
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.maxrave.simpmusic.expect.ui.toReadableBitmap
|
||||
import com.maxrave.simpmusic.extension.smoothScrimBrush
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.random.Random
|
||||
|
||||
/*
|
||||
* The page behind the Apple Music MAIN view: the artwork's own colours reduced to a mesh and hung
|
||||
* from where the artwork stops. The colour directly under the picture is the colour the picture
|
||||
* ended on, so there is no join to hide.
|
||||
*
|
||||
* Ported from BitChord's ArtworkMeshBackdrop (github.com/kushagrasinghx/BitChord, GPL-3.0 — the
|
||||
* same licence as this project). How it is built:
|
||||
* - the artwork is averaged into a MESH_GRID square of means, read bottom-up, so row 0 is its
|
||||
* bottom edge;
|
||||
* - every row but row 0 is shifted sideways by a seeded amount, and mirrored half the time. A flip
|
||||
* alone reads as a reflection on a cover with a face or a logo in it; the shift breaks that
|
||||
* without putting any two colours side by side that were not neighbours in the artwork;
|
||||
* - the grid is resampled to MESH_TEX with smoothstep, which is what makes it smooth. The blur only
|
||||
* takes the last of the edge off.
|
||||
* Proportions survive on purpose: a cover that is mostly black leaves a mostly black page.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A mesh read off one artwork: a small texture, stretched by the sampler when it is drawn, and how
|
||||
* bright the artwork's top band is (0..1), which sets the wash under the status bar.
|
||||
*/
|
||||
@Immutable
|
||||
internal class AppleMusicMesh(
|
||||
val image: ImageBitmap,
|
||||
val topLuminance: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* The mesh for [artwork], or null until one has been read. [seed] fixes the sideways shift, so the
|
||||
* same artwork always lands in the same arrangement. The previous mesh stays until the new one is
|
||||
* ready, so a skip crossfades between two colourings instead of flashing the fallback between them.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberAppleMusicMesh(
|
||||
artwork: ImageBitmap?,
|
||||
seed: Int,
|
||||
): AppleMusicMesh? {
|
||||
var mesh by remember { mutableStateOf<AppleMusicMesh?>(null) }
|
||||
LaunchedEffect(artwork, seed) {
|
||||
val source = artwork ?: return@LaunchedEffect
|
||||
// No suspension inside meshOf, so nothing here can swallow a cancellation.
|
||||
val read = withContext(Dispatchers.Default) { runCatching { meshOf(source, seed) }.getOrNull() }
|
||||
if (read != null) mesh = read
|
||||
}
|
||||
return mesh
|
||||
}
|
||||
|
||||
/**
|
||||
* [mesh] hung from [seam], the Y where the artwork's bottom edge sits: above it the mesh's first row
|
||||
* is held (that stretch is behind the artwork, which dissolves into it), below it the mesh fills the
|
||||
* rest of the page. A new mesh crossfades in over [MESH_FADE_MS].
|
||||
*/
|
||||
@Composable
|
||||
internal fun AppleMusicMeshBackdrop(
|
||||
mesh: AppleMusicMesh?,
|
||||
seam: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Two meshes rather than one animated colour: what crossfades here is a pair of pictures, and
|
||||
// the outgoing one has to stay drawable until the fade has finished with it.
|
||||
var shown by remember { mutableStateOf(mesh) }
|
||||
var incoming by remember { mutableStateOf<AppleMusicMesh?>(null) }
|
||||
val fade = remember { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(mesh) {
|
||||
val next = mesh ?: return@LaunchedEffect
|
||||
// A change mid-fade: what was fading in has been on screen a while, so it is what the new
|
||||
// one fades FROM. Restarting from the mesh before it would never let a quick skip settle.
|
||||
incoming?.let { shown = it }
|
||||
incoming = null
|
||||
val current = shown
|
||||
if (next === current) return@LaunchedEffect
|
||||
if (current == null) {
|
||||
shown = next
|
||||
return@LaunchedEffect
|
||||
}
|
||||
incoming = next
|
||||
fade.snapTo(0f)
|
||||
fade.animateTo(1f, tween(MESH_FADE_MS, easing = FastOutSlowInEasing))
|
||||
shown = next
|
||||
incoming = null
|
||||
}
|
||||
|
||||
Canvas(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
// Outside the blur, so it is an opaque floor the blur cannot thin out — the page is a
|
||||
// sheet over the rest of the app and every pixel of it has to be opaque.
|
||||
.background(MeshFallback)
|
||||
// The default edge treatment clamps; Unbounded would fade alpha in from every edge and
|
||||
// let the page underneath show through down the sides.
|
||||
.blur(MESH_BLUR),
|
||||
) {
|
||||
val seamY = seam.toPx().coerceIn(0f, size.height)
|
||||
shown?.let { drawMesh(it, seamY, alpha = 1f) }
|
||||
// Read here, not in composition: an Animatable read in a draw lambda only redraws.
|
||||
incoming?.let { drawMesh(it, seamY, alpha = fade.value) }
|
||||
// Just enough to keep white text readable over a bright sleeve. The colours are the sleeve's
|
||||
// own, so a sleeve that ends dark should leave a dark page, not a greyed one.
|
||||
drawRect(brush = smoothScrimBrush(from = Color.Black.copy(alpha = 0.06f), to = Color.Black.copy(alpha = 0.30f)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawMesh(
|
||||
mesh: AppleMusicMesh,
|
||||
seamY: Float,
|
||||
alpha: Float,
|
||||
) {
|
||||
if (alpha <= 0.001f) return
|
||||
val width = size.width.roundToInt()
|
||||
val seam = seamY.roundToInt()
|
||||
if (seam > 0) {
|
||||
drawImage(
|
||||
image = mesh.image,
|
||||
srcOffset = IntOffset.Zero,
|
||||
srcSize = IntSize(MESH_TEX, 1),
|
||||
dstOffset = IntOffset.Zero,
|
||||
dstSize = IntSize(width, seam),
|
||||
alpha = alpha,
|
||||
filterQuality = FilterQuality.Low,
|
||||
)
|
||||
}
|
||||
drawImage(
|
||||
image = mesh.image,
|
||||
srcOffset = IntOffset.Zero,
|
||||
srcSize = IntSize(MESH_TEX, MESH_TEX),
|
||||
dstOffset = IntOffset(0, seam),
|
||||
dstSize = IntSize(width, (size.height.roundToInt() - seam).coerceAtLeast(1)),
|
||||
alpha = alpha,
|
||||
filterQuality = FilterQuality.Low,
|
||||
)
|
||||
}
|
||||
|
||||
private fun meshOf(
|
||||
artwork: ImageBitmap,
|
||||
seed: Int,
|
||||
): AppleMusicMesh? {
|
||||
if (artwork.width < 1 || artwork.height < 1) return null
|
||||
// Averaged down to 36 cells, so resolution buys nothing. The height keeps the artwork's own
|
||||
// aspect: squashing one axis would reweight which part of the frame each cell is made of.
|
||||
val width = MESH_SOURCE_PX
|
||||
val height = (MESH_SOURCE_PX.toLong() * artwork.height / artwork.width).toInt().coerceIn(1, MESH_SOURCE_PX * 2)
|
||||
val pixels = IntArray(width * height)
|
||||
artwork.toReadableBitmap(width, height).readPixels(pixels)
|
||||
|
||||
val cols = MESH_GRID
|
||||
val rows = MESH_GRID
|
||||
val cells = cols * rows
|
||||
val red = LongArray(cells)
|
||||
val green = LongArray(cells)
|
||||
val blue = LongArray(cells)
|
||||
val count = IntArray(cells)
|
||||
for (y in 0 until height) {
|
||||
// Flipped as it is read: row 0 of the grid is the artwork's bottom edge.
|
||||
val rowBase = ((height - 1 - y) * rows / height) * cols
|
||||
for (x in 0 until width) {
|
||||
val cell = rowBase + x * cols / width
|
||||
val pixel = pixels[y * width + x]
|
||||
red[cell] += ((pixel shr 16) and 0xFF).toLong()
|
||||
green[cell] += ((pixel shr 8) and 0xFF).toLong()
|
||||
blue[cell] += (pixel and 0xFF).toLong()
|
||||
count[cell]++
|
||||
}
|
||||
}
|
||||
val grid =
|
||||
IntArray(cells) { cell ->
|
||||
val n = count[cell].coerceAtLeast(1)
|
||||
argb((red[cell] / n).toInt(), (green[cell] / n).toInt(), (blue[cell] / n).toInt()).lifted()
|
||||
}
|
||||
// The grid's last row is the artwork's top band, read before any lifting.
|
||||
val topRow = (rows - 1) * cols until rows * cols
|
||||
val topPixels = topRow.sumOf { count[it].toLong() }.coerceAtLeast(1L)
|
||||
val topLuminance =
|
||||
(topRow.sumOf { 0.2126 * red[it] + 0.7152 * green[it] + 0.0722 * blue[it] } / topPixels / 255.0).toFloat().coerceIn(0f, 1f)
|
||||
val texels = grid.rotatedBelowSeam(cols, rows, seed).resampled(cols, rows, MESH_TEX)
|
||||
|
||||
val image = ImageBitmap(MESH_TEX, MESH_TEX)
|
||||
val canvas = Canvas(image)
|
||||
val paint = Paint()
|
||||
for (y in 0 until MESH_TEX) {
|
||||
for (x in 0 until MESH_TEX) {
|
||||
paint.color = Color(texels[y * MESH_TEX + x])
|
||||
canvas.drawRect(x.toFloat(), y.toFloat(), x + 1f, y + 1f, paint)
|
||||
}
|
||||
}
|
||||
return AppleMusicMesh(image, topLuminance)
|
||||
}
|
||||
|
||||
// Every row but the seam row, carried sideways by one seeded amount and mirrored half the time.
|
||||
// A cyclic shift keeps each cell's neighbours, so no colour boundary is invented.
|
||||
private fun IntArray.rotatedBelowSeam(
|
||||
cols: Int,
|
||||
rows: Int,
|
||||
seed: Int,
|
||||
): IntArray {
|
||||
if (rows <= 1) return this
|
||||
val random = Random(seed)
|
||||
val mirror = random.nextBoolean()
|
||||
val shift = random.nextInt(cols)
|
||||
val out = copyOf()
|
||||
for (row in 1 until rows) {
|
||||
val base = row * cols
|
||||
for (x in 0 until cols) {
|
||||
val src = if (mirror) cols - 1 - x else x
|
||||
out[base + x] = this[base + (src + shift) % cols]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Smoothstep between control points: bilinear alone leaves a crease at every cell boundary once the
|
||||
// grid is magnified to a full screen; a curve that is flat at each control point does not.
|
||||
private fun IntArray.resampled(
|
||||
cols: Int,
|
||||
rows: Int,
|
||||
size: Int,
|
||||
): IntArray {
|
||||
val out = IntArray(size * size)
|
||||
for (ty in 0 until size) {
|
||||
val fy = (ty + 0.5f) / size * rows - 0.5f
|
||||
val y0 = floor(fy).toInt().coerceIn(0, rows - 1)
|
||||
val y1 = (y0 + 1).coerceAtMost(rows - 1)
|
||||
val wy = smoothstep(fy - y0)
|
||||
for (tx in 0 until size) {
|
||||
val fx = (tx + 0.5f) / size * cols - 0.5f
|
||||
val x0 = floor(fx).toInt().coerceIn(0, cols - 1)
|
||||
val x1 = (x0 + 1).coerceAtMost(cols - 1)
|
||||
val wx = smoothstep(fx - x0)
|
||||
val top = lerpArgb(this[y0 * cols + x0], this[y0 * cols + x1], wx)
|
||||
val bottom = lerpArgb(this[y1 * cols + x0], this[y1 * cols + x1], wx)
|
||||
out[ty * size + tx] = lerpArgb(top, bottom, wy)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun smoothstep(t: Float): Float {
|
||||
val x = t.coerceIn(0f, 1f)
|
||||
return x * x * (3f - 2f * x)
|
||||
}
|
||||
|
||||
private fun lerpArgb(
|
||||
from: Int,
|
||||
to: Int,
|
||||
t: Float,
|
||||
): Int {
|
||||
if (t <= 0f) return from
|
||||
if (t >= 1f) return to
|
||||
fun channel(shift: Int): Int {
|
||||
val a = (from shr shift) and 0xFF
|
||||
val b = (to shr shift) and 0xFF
|
||||
return (a + (b - a) * t).roundToInt().coerceIn(0, 255)
|
||||
}
|
||||
return argb(channel(16), channel(8), channel(0))
|
||||
}
|
||||
|
||||
private fun argb(
|
||||
red: Int,
|
||||
green: Int,
|
||||
blue: Int,
|
||||
): Int = (0xFF shl 24) or (red shl 16) or (green shl 8) or blue
|
||||
|
||||
// The one liberty taken with the colours: averaging greys a block (the mean of a red stripe and the
|
||||
// black around it is a dull maroon), so saturation gets back roughly what averaging took, and
|
||||
// lightness is floored just off pure black. Neither touches the proportions.
|
||||
private fun Int.lifted(): Int {
|
||||
val r = ((this shr 16) and 0xFF) / 255f
|
||||
val g = ((this shr 8) and 0xFF) / 255f
|
||||
val b = (this and 0xFF) / 255f
|
||||
val maxC = max(r, max(g, b))
|
||||
val minC = min(r, min(g, b))
|
||||
val lightness = (maxC + minC) / 2f
|
||||
val delta = maxC - minC
|
||||
if (delta == 0f) {
|
||||
val grey = (lightness.coerceAtLeast(MESH_FLOOR) * 255f).roundToInt()
|
||||
return argb(grey, grey, grey)
|
||||
}
|
||||
val saturation = delta / (1f - abs(2f * lightness - 1f))
|
||||
val hue =
|
||||
when (maxC) {
|
||||
r -> ((g - b) / delta).mod(6f)
|
||||
g -> (b - r) / delta + 2f
|
||||
else -> (r - g) / delta + 4f
|
||||
} * 60f
|
||||
val color = Color.hsl(hue, (saturation * MESH_VIBRANCE).coerceIn(0f, 1f), lightness.coerceIn(MESH_FLOOR, 1f))
|
||||
return argb((color.red * 255f).roundToInt(), (color.green * 255f).roundToInt(), (color.blue * 255f).roundToInt())
|
||||
}
|
||||
|
||||
/** How many cells across the mesh is: enough that a cover's layout survives, few enough that nothing recognisable does. */
|
||||
private const val MESH_GRID = 6
|
||||
|
||||
/** What the grid is smoothed to before the GPU stretches it. */
|
||||
private const val MESH_TEX = 32
|
||||
|
||||
/** What the artwork is read at before it is averaged — a whole multiple of [MESH_GRID]. */
|
||||
private const val MESH_SOURCE_PX = 120
|
||||
|
||||
private const val MESH_VIBRANCE = 1.12f
|
||||
private const val MESH_FLOOR = 0.045f
|
||||
|
||||
/** How long the page takes to change colour on a skip: long enough to read as a change, not a cut. */
|
||||
private const val MESH_FADE_MS = 900
|
||||
|
||||
// Wider than BitChord's 32dp: the owner asked for the page to be visibly smooth, and at 44dp no
|
||||
// cell edge survives even on a cover with hard colour blocks.
|
||||
private val MESH_BLUR = 44.dp
|
||||
|
||||
/** Drawn only until an artwork has been read. */
|
||||
private val MeshFallback = Color(0xFF121212)
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
package com.maxrave.simpmusic.ui.screen.player.content.applemusic
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.maxrave.domain.data.player.AudioOutput
|
||||
import com.maxrave.domain.data.player.AudioOutputKind
|
||||
import com.maxrave.domain.data.player.GenericCastState
|
||||
import com.maxrave.simpmusic.Platform
|
||||
import com.maxrave.simpmusic.expect.ui.DeviceVolumeController
|
||||
import com.maxrave.simpmusic.expect.ui.rememberCastReceivers
|
||||
import com.maxrave.simpmusic.getPlatform
|
||||
import com.maxrave.simpmusic.ui.component.SurfaceDarkColors
|
||||
import com.maxrave.simpmusic.ui.component.rememberSurfaceDarkColors
|
||||
import com.maxrave.simpmusic.ui.icon.Check
|
||||
import com.maxrave.simpmusic.ui.icon.Headphones
|
||||
import com.maxrave.simpmusic.ui.icon.MusicCast
|
||||
import com.maxrave.simpmusic.ui.icon.SimpIcons
|
||||
import com.maxrave.simpmusic.ui.icon.Smartphone
|
||||
import com.maxrave.simpmusic.ui.icon.Speaker
|
||||
import com.maxrave.simpmusic.ui.theme.typo
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import simpmusic.composeapp.generated.resources.Res
|
||||
import simpmusic.composeapp.generated.resources.audio_output
|
||||
import simpmusic.composeapp.generated.resources.audio_output_bluetooth
|
||||
import simpmusic.composeapp.generated.resources.audio_output_cast
|
||||
import simpmusic.composeapp.generated.resources.audio_output_hdmi
|
||||
import simpmusic.composeapp.generated.resources.audio_output_playing_here
|
||||
import simpmusic.composeapp.generated.resources.audio_output_system_default
|
||||
import simpmusic.composeapp.generated.resources.audio_output_usb
|
||||
import simpmusic.composeapp.generated.resources.audio_output_wired
|
||||
|
||||
/**
|
||||
* Where the sound goes — opened from the headphones half of the action row's capsule.
|
||||
*
|
||||
* The phone's own outputs and the Cast receivers are one list, the way Apple lists AirPlay speakers
|
||||
* beside the iPhone: they answer the same question, so they belong in the same place. Picking a
|
||||
* local output while casting ends the Cast session first. Built in the app's own sheet style (the
|
||||
* same card, handle and rows as the Now Playing ⋯ sheet), not a new one.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun AppleMusicOutputSheet(
|
||||
outputs: List<AudioOutput>,
|
||||
castState: GenericCastState,
|
||||
deviceVolumeController: DeviceVolumeController?,
|
||||
onSelectOutput: (AudioOutput) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val colors = rememberSurfaceDarkColors()
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val localDensity = LocalDensity.current
|
||||
// Scanning for receivers only while this is open: discovery keeps the radio busy.
|
||||
val cast = rememberCastReceivers(discover = true)
|
||||
// Devices coming and going update the list on their own; the system's own route changing does not.
|
||||
LaunchedEffect(Unit) { onRefresh() }
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = Color.Transparent,
|
||||
dragHandle = null,
|
||||
scrimColor = Color.Black.copy(alpha = .5f),
|
||||
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().wrapContentHeight(),
|
||||
shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp),
|
||||
colors = CardDefaults.cardColors().copy(containerColor = colors.container),
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Spacer(modifier = Modifier.height(5.dp))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(60.dp)
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(colors.handle),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(5.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.audio_output),
|
||||
style = typo().labelMedium,
|
||||
color = colors.content,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 6.dp),
|
||||
)
|
||||
outputs.forEach { output ->
|
||||
val playingHere = output.isActive && !castState.isRemote
|
||||
OutputRow(
|
||||
icon = output.icon(),
|
||||
name = output.displayName(),
|
||||
status = if (playingHere) stringResource(Res.string.audio_output_playing_here) else output.kindLabel(),
|
||||
selected = playingHere,
|
||||
colors = colors,
|
||||
onClick = {
|
||||
// Coming back from a receiver: end the Cast session first, or the pick
|
||||
// would route a local player that is not the one playing.
|
||||
if (castState.isRemote) cast.disconnect()
|
||||
onSelectOutput(output)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (cast.receivers.isNotEmpty()) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
|
||||
thickness = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.10f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.audio_output_cast),
|
||||
style = typo().bodySmall,
|
||||
color = colors.subtitle,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 32.dp, end = 20.dp, top = 4.dp),
|
||||
)
|
||||
cast.receivers.forEach { receiver ->
|
||||
OutputRow(
|
||||
icon = SimpIcons.MusicCast,
|
||||
name = receiver.name,
|
||||
status = if (receiver.isConnected) stringResource(Res.string.audio_output_playing_here) else null,
|
||||
selected = receiver.isConnected,
|
||||
colors = colors,
|
||||
onClick = { if (!receiver.isConnected) cast.connect(receiver.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
deviceVolumeController?.let { controller ->
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp),
|
||||
thickness = 1.dp,
|
||||
color = Color.White.copy(alpha = 0.10f),
|
||||
)
|
||||
AppleMusicVolumeRow(controller = controller, modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp))
|
||||
}
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.height(
|
||||
with(localDensity) { WindowInsets.systemBars.getBottom(localDensity).toDp() } + 12.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One output: the ⋯ sheet's ActionButton metrics (48dp icon box, labelSmall), with a status line
|
||||
// under the name and a tick on the one playing.
|
||||
@Composable
|
||||
private fun OutputRow(
|
||||
icon: ImageVector,
|
||||
name: String,
|
||||
status: String?,
|
||||
selected: Boolean,
|
||||
colors: SurfaceDarkColors,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(60.dp)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = icon, contentDescription = null, tint = colors.content, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) {
|
||||
Text(text = name, style = typo().labelSmall, color = colors.content, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (status != null) {
|
||||
Text(text = status, style = typo().bodySmall, color = colors.subtitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = selected, enter = fadeIn() + scaleIn(), exit = fadeOut() + scaleOut()) {
|
||||
Icon(imageVector = SimpIcons.Check, contentDescription = null, tint = colors.content, modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What to call an output. A backend leaves the name blank when all it has is meaningless — the
|
||||
* desktop's "auto", or a wire reporting the phone's own model — and the kind names it instead.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AudioOutput.displayName(): String =
|
||||
name.ifBlank {
|
||||
when (kind) {
|
||||
AudioOutputKind.DEVICE_SPEAKER -> stringResource(Res.string.audio_output_system_default)
|
||||
AudioOutputKind.WIRED -> stringResource(Res.string.audio_output_wired)
|
||||
AudioOutputKind.BLUETOOTH -> stringResource(Res.string.audio_output_bluetooth)
|
||||
AudioOutputKind.USB -> stringResource(Res.string.audio_output_usb)
|
||||
AudioOutputKind.HDMI -> stringResource(Res.string.audio_output_hdmi)
|
||||
AudioOutputKind.OTHER -> stringResource(Res.string.audio_output_system_default)
|
||||
}
|
||||
}
|
||||
|
||||
// The second line under a device that is not playing: what kind of connection it is, when the name
|
||||
// alone does not say so.
|
||||
@Composable
|
||||
private fun AudioOutput.kindLabel(): String? =
|
||||
when (kind) {
|
||||
AudioOutputKind.BLUETOOTH -> stringResource(Res.string.audio_output_bluetooth)
|
||||
AudioOutputKind.USB -> if (name.isNotBlank()) stringResource(Res.string.audio_output_usb) else null
|
||||
AudioOutputKind.HDMI -> if (name.isNotBlank()) stringResource(Res.string.audio_output_hdmi) else null
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun AudioOutput.icon(): ImageVector =
|
||||
when (kind) {
|
||||
// The phone's own speaker on Android; the system default on desktop, which is a computer.
|
||||
AudioOutputKind.DEVICE_SPEAKER -> if (getPlatform() == Platform.Android) SimpIcons.Smartphone else SimpIcons.Speaker
|
||||
AudioOutputKind.WIRED, AudioOutputKind.BLUETOOTH, AudioOutputKind.USB -> SimpIcons.Headphones
|
||||
AudioOutputKind.HDMI, AudioOutputKind.OTHER -> SimpIcons.Speaker
|
||||
}
|
||||
|
|
@ -98,6 +98,8 @@ internal fun AppleMusicQueueView(
|
|||
activePillContainer: Color,
|
||||
activePillContent: Color,
|
||||
deviceVolumeController: DeviceVolumeController?,
|
||||
outputName: String?,
|
||||
onOpenOutput: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
dataStoreManager: DataStoreManager = koinInject(),
|
||||
musicServiceHandler: MediaPlayerHandler = koinInject(),
|
||||
|
|
@ -310,6 +312,8 @@ internal fun AppleMusicQueueView(
|
|||
activePillContainer = activePillContainer,
|
||||
activePillContent = activePillContent,
|
||||
deviceVolumeController = deviceVolumeController,
|
||||
outputName = outputName,
|
||||
onOpenOutput = onOpenOutput,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.maxrave.simpmusic.ui.screen.player.content.applemusic
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
|
|
@ -11,6 +12,7 @@ import androidx.compose.animation.core.rememberInfiniteTransition
|
|||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
|
|
@ -20,6 +22,7 @@ import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
|||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -66,18 +69,17 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.CachePolicy
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import com.maxrave.domain.data.player.GenericCastState
|
||||
import com.maxrave.domain.mediaservice.handler.ControlState
|
||||
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.expect.ui.PlatformCastButton
|
||||
import com.maxrave.simpmusic.expect.ui.isPlatformCastAvailable
|
||||
import com.maxrave.simpmusic.extension.formatDuration
|
||||
import com.maxrave.simpmusic.getPlatform
|
||||
import com.maxrave.simpmusic.ui.component.ExplicitBadge
|
||||
|
|
@ -88,7 +90,8 @@ import com.maxrave.simpmusic.ui.icon.AddCircleOutline
|
|||
import com.maxrave.simpmusic.ui.icon.CheckCircle
|
||||
import com.maxrave.simpmusic.ui.icon.FastForward
|
||||
import com.maxrave.simpmusic.ui.icon.FastRewind
|
||||
import com.maxrave.simpmusic.ui.icon.GraphicEq
|
||||
import com.maxrave.simpmusic.ui.icon.Groups
|
||||
import com.maxrave.simpmusic.ui.icon.Headphones
|
||||
import com.maxrave.simpmusic.ui.icon.Lyrics
|
||||
import com.maxrave.simpmusic.ui.icon.MoreVert
|
||||
import com.maxrave.simpmusic.ui.icon.Pause
|
||||
|
|
@ -110,6 +113,7 @@ import org.jetbrains.compose.resources.stringResource
|
|||
import simpmusic.composeapp.generated.resources.Res
|
||||
import simpmusic.composeapp.generated.resources.crossfading
|
||||
import kotlin.math.roundToLong
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
/** Which body the dock is currently showing. Held by the top-level Apple Music composable. */
|
||||
internal enum class AppleMusicView { MAIN, LYRICS, QUEUE }
|
||||
|
|
@ -137,6 +141,10 @@ internal fun appleMusicGradientColorAt(
|
|||
}
|
||||
|
||||
internal val AppleMusicTextSecondary = Color.White.copy(alpha = 0.72f)
|
||||
|
||||
// Artist, times and captions — BitChord's value for the same lines (PlayerControls.kt,
|
||||
// NowPlayingScreen.kt).
|
||||
internal val AppleMusicSecondaryText = Color.White.copy(alpha = 0.55f)
|
||||
internal val AppleMusicPillInactive = Color.White.copy(alpha = 0.24f)
|
||||
internal val AppleMusicTrackInactive = Color.White.copy(alpha = 0.26f)
|
||||
internal val AppleMusicTrackActive = Color.White.copy(alpha = 0.92f)
|
||||
|
|
@ -150,10 +158,10 @@ internal data class AppleMusicTypography(
|
|||
val queueSectionHeader: TextStyle,
|
||||
val queueSectionSubtitle: TextStyle,
|
||||
val times: TextStyle,
|
||||
val badge: TextStyle,
|
||||
val footer: TextStyle,
|
||||
val idleLyric: TextStyle,
|
||||
val idleTranslated: TextStyle,
|
||||
val lyricStrip: TextStyle,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -162,29 +170,34 @@ internal data class AppleMusicTypography(
|
|||
* the app). Precedents: M3E's track title/artist row (titleMedium/bodyMedium), LyricsView's
|
||||
* in-player line (headlineMedium), SongFullWidthItems rows (titleSmall/bodySmall), the queue
|
||||
* sheet's section headers (titleMedium) and M3E's canvas overlay lines (bodyMedium white/yellow).
|
||||
*
|
||||
* Secondary text is BitChord's translucent white rather than typo()'s body grey: an opaque
|
||||
* #A8A8A8 is darker than a bright sleeve's page and read as a dark grey smudge on it, where a
|
||||
* translucent white stays lighter than whatever it sits on.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberAppleMusicTypography(): AppleMusicTypography {
|
||||
val t = typo()
|
||||
return AppleMusicTypography(
|
||||
mainTitle = t.titleMedium,
|
||||
mainArtist = t.bodyMedium,
|
||||
mainArtist = t.bodyMedium.copy(color = AppleMusicSecondaryText),
|
||||
// The compact header — Lyrics, Queue, and the row under a running canvas — carries the
|
||||
// SIZES FullscreenLyricsSheet uses for the same job (LyricsView.kt: labelSmall over
|
||||
// bodySmall), while mainTitle/mainArtist stay 18/13 because they head the controller
|
||||
// layout. Borrow the size from labelSmall but keep the titleMedium ROLE: typo() bakes a
|
||||
// color into every role — title* carry titleColor, body*/label* carry bodyColor — so
|
||||
// switching the title to labelSmall outright would also switch it to the subtitle's grey.
|
||||
// The artist was already a body role, so bodySmall changes its size and nothing else.
|
||||
compactTitle = t.titleMedium.copy(fontSize = t.labelSmall.fontSize),
|
||||
compactArtist = t.bodySmall,
|
||||
compactArtist = t.bodySmall.copy(color = AppleMusicSecondaryText),
|
||||
queueSectionHeader = t.titleMedium,
|
||||
queueSectionSubtitle = t.bodySmall,
|
||||
times = t.bodyMedium,
|
||||
badge = t.bodySmall,
|
||||
footer = t.bodySmall,
|
||||
queueSectionSubtitle = t.bodySmall.copy(color = AppleMusicSecondaryText),
|
||||
times = t.bodyMedium.copy(color = AppleMusicSecondaryText),
|
||||
footer = t.bodySmall.copy(color = AppleMusicSecondaryText),
|
||||
idleLyric = t.bodyMedium.copy(color = Color.White),
|
||||
idleTranslated = t.bodyMedium.copy(color = Color.Yellow),
|
||||
// The line over the progress bar is the inline lyric line Classic and M3E draw under their
|
||||
// artwork: labelSmall in white, the same role and colour.
|
||||
lyricStrip = t.labelSmall.copy(color = Color.White),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -192,6 +205,10 @@ internal fun rememberAppleMusicTypography(): AppleMusicTypography {
|
|||
* True alpha fade at the top/bottom edges of a scrolling region (DstIn mask): content dissolves
|
||||
* into whatever is behind it — the missing "scrim" on the lyrics list's hard-clipped edges —
|
||||
* without painting a color and without touching the wrapped component.
|
||||
*
|
||||
* Both ramps follow smoothstep, the curve [com.maxrave.simpmusic.extension.smoothScrimBrush] uses and
|
||||
* for the reason it gives: a linear ramp has a corner where it leaves 0, and the eye reads that corner
|
||||
* as an edge — on the artwork it was a visible line where the picture started to dissolve.
|
||||
*/
|
||||
internal fun Modifier.appleMusicVerticalFadeEdges(
|
||||
topFade: Dp,
|
||||
|
|
@ -204,18 +221,64 @@ internal fun Modifier.appleMusicVerticalFadeEdges(
|
|||
val bottomPx = bottomFade.toPx().coerceAtMost(size.height / 2f)
|
||||
val topStop = if (size.height > 0f) topPx / size.height else 0f
|
||||
val bottomStop = if (size.height > 0f) 1f - bottomPx / size.height else 1f
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(colorStops = fadeEdgeStops(topStop, bottomStop)),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transparent at [transparentAt] and beyond it, opaque at [opaqueAt] and beyond it, on the same
|
||||
* smoothstep curve as [appleMusicVerticalFadeEdges] — a fade placed anywhere in the element rather
|
||||
* than at its edge, in either direction.
|
||||
*/
|
||||
internal fun Modifier.appleMusicFadeBetween(
|
||||
transparentAt: Dp,
|
||||
opaqueAt: Dp,
|
||||
): Modifier =
|
||||
graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
drawRect(
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Transparent,
|
||||
topStop to Color.Black,
|
||||
bottomStop to Color.Black,
|
||||
1f to Color.Transparent,
|
||||
colorStops = fadeEdgeStops(1f, 1f),
|
||||
startY = transparentAt.toPx(),
|
||||
endY = opaqueAt.toPx(),
|
||||
),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
}
|
||||
|
||||
private const val FADE_EDGE_STEPS = 24
|
||||
|
||||
private fun smoothstep(t: Float): Float = t * t * (3f - 2f * t)
|
||||
|
||||
// Transparent → opaque over [0, topStop], held, then opaque → transparent over [bottomStop, 1]. A
|
||||
// zero-length ramp is skipped rather than drawn as a pile of stops at one position.
|
||||
private fun fadeEdgeStops(
|
||||
topStop: Float,
|
||||
bottomStop: Float,
|
||||
): Array<Pair<Float, Color>> =
|
||||
buildList {
|
||||
if (topStop > 0f) {
|
||||
for (i in 0..FADE_EDGE_STEPS) {
|
||||
val t = i / FADE_EDGE_STEPS.toFloat()
|
||||
add(topStop * t to Color.Black.copy(alpha = smoothstep(t)))
|
||||
}
|
||||
} else {
|
||||
add(0f to Color.Black)
|
||||
}
|
||||
if (bottomStop < 1f) {
|
||||
for (i in 0..FADE_EDGE_STEPS) {
|
||||
val t = i / FADE_EDGE_STEPS.toFloat()
|
||||
add(bottomStop + (1f - bottomStop) * t to Color.Black.copy(alpha = 1f - smoothstep(t)))
|
||||
}
|
||||
} else {
|
||||
add(1f to Color.Black)
|
||||
}
|
||||
}.toTypedArray()
|
||||
|
||||
/**
|
||||
* Press-to-swell ("phồng to ra") like the liquid-glass buttons: the control springs up while a
|
||||
* finger is on it and settles back on release.
|
||||
|
|
@ -391,7 +454,7 @@ internal fun AppleMusicCompactHeader(
|
|||
}
|
||||
}
|
||||
|
||||
/** 7dp track shared by the progress bar and the volume row — same visual language, only the color and callbacks differ. */
|
||||
/** 8.4dp track shared by the progress bar and the volume row — same visual language, only the color and callbacks differ. */
|
||||
@Composable
|
||||
internal fun AppleMusicThinSlider(
|
||||
value: Float,
|
||||
|
|
@ -405,8 +468,10 @@ internal fun AppleMusicThinSlider(
|
|||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val pressed by interactionSource.collectIsPressedAsState()
|
||||
val dragged by interactionSource.collectIsDraggedAsState()
|
||||
// 20% thicker than the original 7dp/14dp at the owner's request, so the bar is easier to hit.
|
||||
// The swollen height still fits the 18dp shells both callers wrap it in.
|
||||
val trackHeight by animateDpAsState(
|
||||
targetValue = if (pressed || dragged) 14.dp else 7.dp,
|
||||
targetValue = if (pressed || dragged) 16.8.dp else 8.4.dp,
|
||||
animationSpec = spring(dampingRatio = 0.5f, stiffness = 300f),
|
||||
label = "appleMusicSliderInflate",
|
||||
)
|
||||
|
|
@ -453,7 +518,7 @@ internal fun AppleMusicThinSlider(
|
|||
}
|
||||
}
|
||||
|
||||
/** Elapsed time, the codec badge (hidden when unknown), and the remaining time as "-m:ss". */
|
||||
/** Elapsed time, the quality line (hidden when unknown), and the remaining time as "-m:ss". */
|
||||
@Composable
|
||||
internal fun AppleMusicTimesRow(
|
||||
state: NowPlayingContentState,
|
||||
|
|
@ -478,23 +543,35 @@ internal fun AppleMusicTimesRow(
|
|||
// must never show that at the end of a track whose length IS known. An UNKNOWN length is
|
||||
// exactly what that string is for, so null deliberately takes the negative path below.
|
||||
val remainingMs = knownTotal?.let { (it - elapsedMs).coerceAtLeast(0L) }
|
||||
Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = formatDuration(elapsedMs),
|
||||
style = typography.times,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Left,
|
||||
)
|
||||
// A Box, not a three-way weighted Row: the quality line ("154 kbps · 48 kHz") is far wider than
|
||||
// the codec pill it replaced, and a third of the row wrapped it onto two lines. Pinned to the
|
||||
// row's own centre it has everything between the two times, and a minute rolling over on
|
||||
// either side cannot nudge it.
|
||||
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = formatDuration(elapsedMs),
|
||||
style = typography.times,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Left,
|
||||
)
|
||||
Text(
|
||||
// No leading "-" when the length is unknown: "-NA:NA" reads as a negative amount of
|
||||
// nothing. formatDuration's own out-of-range string is the app's established way to
|
||||
// say "no value here".
|
||||
text = remainingMs?.let { "-" + formatDuration(it) } ?: formatDuration(-1L),
|
||||
style = typography.times,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Right,
|
||||
)
|
||||
}
|
||||
// ONE slot holding two alternatives — and both of them stay composed, swapping on alpha
|
||||
// rather than on presence. AnimatedVisibility removes its content from the LAYOUT, so this
|
||||
// Box took the height of whichever state was up: the codec pill is a 15dp icon wrapped in
|
||||
// 4dp of vertical padding, "Crossfading" is bare text at the times style, and with NEITHER
|
||||
// showing (no codec, not crossfading) the Box collapsed to nothing at all. Every swap
|
||||
// therefore resized this row and shoved the whole transport below it up or down. Holding
|
||||
// both means the slot is always as tall as the tallest one, at any type scale, with no
|
||||
// measured constant to keep in sync. The cross-fade looks identical — alpha is what
|
||||
// fadeIn/fadeOut animated anyway.
|
||||
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
|
||||
// rather than on presence. AnimatedVisibility removes its content from the LAYOUT, so the
|
||||
// slot would take the height of whichever state was up, and with NEITHER showing (no
|
||||
// quality known, not crossfading) it would collapse to nothing — every swap resizing this
|
||||
// row and shoving the transport below it. Holding both keeps the height fixed. The
|
||||
// cross-fade looks identical — alpha is what fadeIn/fadeOut animated anyway.
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
// Sweep head for the "Crossfading" shimmer, 0..1. Runs UNCONDITIONALLY — put behind the
|
||||
// crossfade check it would restart from zero every time the label appears, which is the
|
||||
// same reason the other two styles declare it outside their own visibility gate.
|
||||
|
|
@ -509,14 +586,14 @@ internal fun AppleMusicTimesRow(
|
|||
),
|
||||
label = "appleMusicSweepHead",
|
||||
)
|
||||
val codec = state.audioCodecLabel
|
||||
val quality = state.audioQualityLabel
|
||||
val crossfadeLabelAlpha by animateFloatAsState(
|
||||
targetValue = if (state.timelineState.isCrossfading) 1f else 0f,
|
||||
label = "appleMusicCrossfadeLabelAlpha",
|
||||
)
|
||||
val codecBadgeAlpha by animateFloatAsState(
|
||||
targetValue = if (!state.timelineState.isCrossfading && codec != null) 1f else 0f,
|
||||
label = "appleMusicCodecBadgeAlpha",
|
||||
val qualityLabelAlpha by animateFloatAsState(
|
||||
targetValue = if (!state.timelineState.isCrossfading && quality != null) 1f else 0f,
|
||||
label = "appleMusicQualityLabelAlpha",
|
||||
)
|
||||
Box(modifier = Modifier.alpha(crossfadeLabelAlpha)) {
|
||||
// Identical treatment to Classic and M3 Expressive: a highlight swept through the
|
||||
|
|
@ -543,44 +620,40 @@ internal fun AppleMusicTimesRow(
|
|||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
// A PILL, like the mock's badge (and Apple's "Lossless"): translucent rounded
|
||||
// background, not bare text floating between the two times. It is the TALLER of the two
|
||||
// states, so it is what the slot's height ends up being — see the note above.
|
||||
Box(modifier = Modifier.alpha(codecBadgeAlpha)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
.background(Color.White.copy(alpha = 0.16f))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
) {
|
||||
// BitChord's quality line: a small headphones glyph ahead of the stream's own figures,
|
||||
// bare on the page and dimmer than the times either side of it. The figures change
|
||||
// with the track and so crossfade in with it rather than snapping.
|
||||
Crossfade(
|
||||
targetState = quality,
|
||||
animationSpec = tween(300),
|
||||
label = "appleMusicQualityLabel",
|
||||
modifier = Modifier.alpha(qualityLabelAlpha),
|
||||
) { label ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = SimpIcons.GraphicEq,
|
||||
contentDescription = "",
|
||||
tint = Color.White.copy(alpha = 0.9f),
|
||||
modifier = Modifier.size(15.dp),
|
||||
imageVector = SimpIcons.Headphones,
|
||||
contentDescription = null,
|
||||
tint = AppleMusicQualityLabel,
|
||||
modifier = Modifier.size(13.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
// orEmpty(), not codec!!: the null check drives the alpha above rather than
|
||||
// guarding this branch, so there is nothing here for the compiler to
|
||||
// smart-cast. It also renders while alpha is 0, which is the point.
|
||||
Text(text = codec.orEmpty(), style = typography.badge.copy(color = Color.White.copy(alpha = 0.9f)))
|
||||
// orEmpty(), not label!!: the null check drives the alpha above rather than
|
||||
// guarding this branch. It also renders while alpha is 0, which keeps the slot
|
||||
// its height.
|
||||
Text(
|
||||
text = label.orEmpty(),
|
||||
style = typography.times.copy(color = AppleMusicQualityLabel),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
// No leading "-" when the length is unknown: "-NA:NA" reads as a negative amount of
|
||||
// nothing. formatDuration's own out-of-range string is the app's established way to
|
||||
// say "no value here".
|
||||
text = remainingMs?.let { "-" + formatDuration(it) } ?: formatDuration(-1L),
|
||||
style = typography.times,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Right,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The quality line sits a step below the times either side of it, as BitChord's does.
|
||||
private val AppleMusicQualityLabel = Color.White.copy(alpha = 0.45f)
|
||||
|
||||
/** FastRewind(44dp) → Previous, Play/Pause(62dp, plain white — no container disc), FastForward(44dp) → Next. */
|
||||
@Composable
|
||||
internal fun AppleMusicTransportRow(
|
||||
|
|
@ -734,56 +807,141 @@ internal fun AppleMusicDockButton(
|
|||
}
|
||||
|
||||
/**
|
||||
* Lyrics · Cast · Queue. The Cast slot renders [PlatformCastButton] itself (which hides when
|
||||
* Cast is unavailable) and takes no "active" tint of its own — same rule M3E's connected group
|
||||
* follows for its Cast slot.
|
||||
* Lyrics · [Output | Listen Together] · Queue — BitChord's arrangement. The capsule holds the two
|
||||
* answers to "where is this playing": which speaker (the output sheet, which is also where Cast
|
||||
* lives now) and which people (Listen Together). Joined by a hairline rather than a gap, so the
|
||||
* two read as one object.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AppleMusicDock(
|
||||
internal fun AppleMusicActionRow(
|
||||
viewState: AppleMusicView,
|
||||
onSelectView: (AppleMusicView) -> Unit,
|
||||
castState: GenericCastState,
|
||||
lyricsAvailable: Boolean,
|
||||
activeColor: Color,
|
||||
activeContentColor: Color,
|
||||
onOpenOutput: () -> Unit,
|
||||
onOpenListenTogether: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Re-tapping the active tab returns to MAIN — the dock is a toggle, not one-way nav.
|
||||
AppleMusicDockButton(
|
||||
icon = SimpIcons.Lyrics,
|
||||
active = viewState == AppleMusicView.LYRICS,
|
||||
activeColor = activeColor,
|
||||
activeContentColor = activeContentColor,
|
||||
enabled = lyricsAvailable,
|
||||
onClick = {
|
||||
onSelectView(if (viewState == AppleMusicView.LYRICS) AppleMusicView.MAIN else AppleMusicView.LYRICS)
|
||||
},
|
||||
)
|
||||
if (isPlatformCastAvailable()) {
|
||||
Box(modifier = Modifier.appleMusicPressInflate().size(40.dp), contentAlignment = Alignment.Center) {
|
||||
PlatformCastButton(
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (castState.isRemote) activeColor else Color.White,
|
||||
// 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>()
|
||||
val room by listenTogether.room.collectAsStateWithLifecycle()
|
||||
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
||||
// A quarter of the spare width at each end, the rest between — BitChord's spacing. SpaceEvenly
|
||||
// over the full row pushed the two glyphs out to the gutters, away from the capsule.
|
||||
val content = DOCK_BUTTON_SIZE * 2 + CAPSULE_SEGMENT_WIDTH * 2 + 1.dp
|
||||
val edgeInset = ((maxWidth - content) / 4).coerceAtLeast(0.dp)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = edgeInset),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Re-tapping the active tab returns to MAIN — the dock is a toggle, not one-way nav.
|
||||
AppleMusicDockButton(
|
||||
icon = SimpIcons.Lyrics,
|
||||
active = viewState == AppleMusicView.LYRICS,
|
||||
activeColor = activeColor,
|
||||
activeContentColor = activeContentColor,
|
||||
enabled = lyricsAvailable,
|
||||
onClick = {
|
||||
onSelectView(if (viewState == AppleMusicView.LYRICS) AppleMusicView.MAIN else AppleMusicView.LYRICS)
|
||||
},
|
||||
)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.height(DOCK_BUTTON_SIZE)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.12f)),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AppleMusicCapsuleSegment(
|
||||
icon = SimpIcons.Headphones,
|
||||
// Headphones is a tall, narrow glyph and Groups a wide one; these are the sizes at
|
||||
// which the two read as a matched pair rather than one looking bigger.
|
||||
iconSize = 23.dp,
|
||||
highlighted = false,
|
||||
onClick = onOpenOutput,
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(1.dp)
|
||||
.fillMaxHeight()
|
||||
.background(Color.White.copy(alpha = 0.20f)),
|
||||
)
|
||||
AppleMusicCapsuleSegment(
|
||||
icon = SimpIcons.Groups,
|
||||
iconSize = 24.dp,
|
||||
highlighted = room.inRoom,
|
||||
onClick = onOpenListenTogether,
|
||||
)
|
||||
}
|
||||
AppleMusicDockButton(
|
||||
icon = SimpIcons.QueueMusic,
|
||||
active = viewState == AppleMusicView.QUEUE,
|
||||
activeColor = activeColor,
|
||||
activeContentColor = activeContentColor,
|
||||
onClick = {
|
||||
onSelectView(if (viewState == AppleMusicView.QUEUE) AppleMusicView.MAIN else AppleMusicView.QUEUE)
|
||||
},
|
||||
)
|
||||
}
|
||||
AppleMusicDockButton(
|
||||
icon = SimpIcons.QueueMusic,
|
||||
active = viewState == AppleMusicView.QUEUE,
|
||||
activeColor = activeColor,
|
||||
activeContentColor = activeContentColor,
|
||||
onClick = {
|
||||
onSelectView(if (viewState == AppleMusicView.QUEUE) AppleMusicView.MAIN else AppleMusicView.QUEUE)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One half of the capsule. Its highlight fills the segment edge to edge — a circle sized to the
|
||||
* glyph, as the dock buttons use, would stop the join reading as one object. The glyph swells on
|
||||
* press like every other control in this style; the segment itself cannot, or it would spill past
|
||||
* the capsule's rounded ends.
|
||||
*/
|
||||
@Composable
|
||||
private fun AppleMusicCapsuleSegment(
|
||||
icon: ImageVector,
|
||||
iconSize: Dp,
|
||||
highlighted: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val pressed by interactionSource.collectIsPressedAsState()
|
||||
val glyphScale by animateFloatAsState(
|
||||
targetValue = if (pressed) 1.25f else 1f,
|
||||
animationSpec = spring(dampingRatio = 0.45f, stiffness = 380f),
|
||||
label = "appleMusicCapsuleGlyph",
|
||||
)
|
||||
val background by animateColorAsState(
|
||||
targetValue = if (highlighted) Color.White.copy(alpha = 0.14f) else Color.Transparent,
|
||||
animationSpec = tween(220),
|
||||
label = "appleMusicCapsuleHighlight",
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(CAPSULE_SEGMENT_WIDTH)
|
||||
.fillMaxHeight()
|
||||
.background(background)
|
||||
.clickable(interactionSource = interactionSource, indication = LocalIndication.current, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (highlighted) Color.White else Color.White.copy(alpha = 0.85f),
|
||||
modifier =
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
scaleX = glyphScale
|
||||
scaleY = glyphScale
|
||||
}.size(iconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val DOCK_BUTTON_SIZE = 40.dp
|
||||
private val CAPSULE_SEGMENT_WIDTH = 64.dp
|
||||
|
||||
/**
|
||||
* Progress bar + times + transport — the playback half of [AppleMusicBottomCluster], shared with
|
||||
* the fullscreen lyrics landscape layout. Emits straight into the caller's Column; the horizontal
|
||||
|
|
@ -798,7 +956,7 @@ internal fun ColumnScope.AppleMusicPlaybackControls(
|
|||
) {
|
||||
// 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 7dp.
|
||||
// it visibly jumps. It also gives the bar a real 18dp touch target instead of 8.4dp.
|
||||
Box(modifier = Modifier.fillMaxWidth().height(18.dp), contentAlignment = Alignment.Center) {
|
||||
AppleMusicThinSlider(
|
||||
value = state.sliderValue / 100f,
|
||||
|
|
@ -809,7 +967,7 @@ internal fun ColumnScope.AppleMusicPlaybackControls(
|
|||
)
|
||||
}
|
||||
AppleMusicTimesRow(state = state, typography = typography, modifier = Modifier.padding(top = 8.dp))
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Spacer(modifier = Modifier.height(22.dp))
|
||||
AppleMusicTransportRow(
|
||||
controllerState = state.controllerState,
|
||||
onUIEvent = actions.onUIEvent,
|
||||
|
|
@ -818,9 +976,13 @@ internal fun ColumnScope.AppleMusicPlaybackControls(
|
|||
}
|
||||
|
||||
/**
|
||||
* Progress bar + times + transport + volume + dock — the fixed block every Apple Music body
|
||||
* (MAIN, LYRICS, QUEUE) renders at the bottom, identically. On Desktop only the dock renders
|
||||
* (no slider/transport/volume), matching the `Platform.Android` gate the other two styles use.
|
||||
* Progress bar + times + transport + volume + action row + output caption — the fixed block every
|
||||
* Apple Music body (MAIN, LYRICS, QUEUE) renders at the bottom, identically. On Desktop only the
|
||||
* action row renders (no slider/transport/volume), matching the `Platform.Android` gate the other
|
||||
* two styles use.
|
||||
*
|
||||
* [outputName] names the device the sound leaves by ("maxrave's Phone", a Bluetooth headset, a
|
||||
* Cast receiver); the slot keeps its height while it is unknown, so nothing above it moves.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AppleMusicBottomCluster(
|
||||
|
|
@ -832,35 +994,66 @@ internal fun AppleMusicBottomCluster(
|
|||
activePillContainer: Color,
|
||||
activePillContent: Color,
|
||||
deviceVolumeController: DeviceVolumeController?,
|
||||
outputName: String?,
|
||||
onOpenOutput: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
topPadding: Dp = 8.dp,
|
||||
) {
|
||||
val localDensity = LocalDensity.current
|
||||
Column(modifier = modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(top = 8.dp)) {
|
||||
Column(modifier = modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(top = topPadding)) {
|
||||
if (getPlatform() == Platform.Android) {
|
||||
AppleMusicPlaybackControls(state = state, actions = actions, typography = typography)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
deviceVolumeController?.let { controller ->
|
||||
AppleMusicVolumeRow(controller = controller)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
AppleMusicDock(
|
||||
AppleMusicActionRow(
|
||||
viewState = viewState,
|
||||
onSelectView = onSelectView,
|
||||
castState = state.castState,
|
||||
lyricsAvailable = state.screenData.lyricsData != null,
|
||||
activeColor = activePillContainer,
|
||||
activeContentColor = activePillContent,
|
||||
onOpenOutput = onOpenOutput,
|
||||
onOpenListenTogether = actions.onOpenListenTogether,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
// Crossfaded, since it changes on its own — a headset connecting renames the line under the
|
||||
// user's thumb. Tappable like BitChord's: it names the output, so it opens the output sheet.
|
||||
Crossfade(
|
||||
targetState = outputName.orEmpty(),
|
||||
animationSpec = tween(300),
|
||||
label = "appleMusicOutputCaption",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { name ->
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = name,
|
||||
style = typography.footer,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(0.65f)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onOpenOutput,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.height(
|
||||
// Breathing room under the dock: the bare inset parked the icons right on the
|
||||
// gesture bar.
|
||||
with(localDensity) { WindowInsets.systemBars.getBottom(localDensity).toDp() } + 12.dp,
|
||||
// Room under the caption, above the gesture bar — the owner asked for the whole
|
||||
// block to sit higher than the bare inset put it.
|
||||
with(localDensity) { WindowInsets.systemBars.getBottom(localDensity).toDp() } + 36.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -635,6 +635,7 @@ class SharedViewModel(
|
|||
NowPlayingScreenData.CanvasData(
|
||||
isVideo = data.isVideo,
|
||||
url = data.canvasUrl,
|
||||
thumbUrl = data.canvasThumbUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -656,6 +657,7 @@ class SharedViewModel(
|
|||
NowPlayingScreenData.CanvasData(
|
||||
isVideo = url.isCanvasVideoUrl(),
|
||||
url = url,
|
||||
thumbUrl = nowPlayingState.value?.songEntity?.canvasThumbUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -2172,6 +2174,10 @@ data class NowPlayingScreenData(
|
|||
data class CanvasData(
|
||||
val isVideo: Boolean,
|
||||
val url: String,
|
||||
// A still of the clip at the clip's own size — Apple Music's animated artwork sends one.
|
||||
// The Apple Music player shows it under the clip while that loads, and reads the clip's
|
||||
// proportions off it, so the frame is the right shape before the first video frame arrives.
|
||||
val thumbUrl: String? = null,
|
||||
)
|
||||
|
||||
data class LyricsData(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import androidx.compose.ui.graphics.ImageBitmap
|
|||
import androidx.compose.ui.graphics.asComposeImageBitmap
|
||||
import androidx.compose.ui.graphics.asSkiaBitmap
|
||||
import coil3.toBitmap
|
||||
import com.maxrave.simpmusic.extension.toResizedBitmap
|
||||
import org.jetbrains.skia.EncodedImageFormat
|
||||
import org.jetbrains.skia.Image
|
||||
|
||||
|
|
@ -21,3 +22,9 @@ actual fun ImageBitmap.toPngByteArray(): ByteArray? {
|
|||
|
||||
actual fun coil3.Image.toImageBitmap(): ImageBitmap =
|
||||
this.toBitmap().asComposeImageBitmap()
|
||||
|
||||
// Skia bitmaps live in memory and can always be read, so a plain resize is enough here.
|
||||
actual fun ImageBitmap.toReadableBitmap(
|
||||
width: Int,
|
||||
height: Int,
|
||||
): ImageBitmap = toResizedBitmap(width, height)
|
||||
|
|
|
|||
|
|
@ -13,3 +13,10 @@ actual fun PlatformCastButton(
|
|||
}
|
||||
|
||||
actual fun isPlatformCastAvailable(): Boolean = false
|
||||
|
||||
@Composable
|
||||
actual fun rememberCastReceivers(discover: Boolean): CastReceivers = NoCastReceivers
|
||||
|
||||
// Desktop has no Google Cast sender, so the list is always empty and the actions do nothing.
|
||||
private val NoCastReceivers = CastReceivers(receivers = emptyList(), connect = {}, disconnect = {})
|
||||
|
||||
|
|
|
|||
2
core
2
core
|
|
@ -1 +1 @@
|
|||
Subproject commit 9950eb133153dd4f4750e808c21f22b5202ef821
|
||||
Subproject commit 6d6b17cfae33292eaa63f4d45427c5c9240fbd64
|
||||
Loading…
Reference in a new issue