From f716aad4f10ac9ecdf9fc7d27b95ad4579f2706f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:09:09 +0000 Subject: [PATCH 01/15] core: a gapless transition without Ended advances the session; transient focus loss no longer pauses for good Media3's auto-advance reports only TransitionedToNext. The actor waited for an Ended that never came: the document stayed on the finished item, the new item's reports were dropped as foreign (so every device showed the previous song, and Next restarted the one already playing), and when that item ended its Ended was dropped too, so playback stopped for good. A transition to the preloaded item now implies the end of the current one. A transient audio-focus loss paused the backend, which cancels Media3's own resume when focus returns. It now only records that nothing is audible; the backend resumes and reports Playing. A pause during the loss still reaches it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY --- crates/hocket-core/src/audio/external.rs | 14 ++ crates/hocket-core/src/audio/scripted.rs | 17 ++- .../hocket-core/src/core/handlers/playback.rs | 49 ++++++- crates/hocket-core/src/core/state.rs | 3 + crates/hocket-core/tests/actor_playback.rs | 120 ++++++++++++++++++ 5 files changed, 198 insertions(+), 5 deletions(-) diff --git a/crates/hocket-core/src/audio/external.rs b/crates/hocket-core/src/audio/external.rs index 7137c9e..a47b591 100644 --- a/crates/hocket-core/src/audio/external.rs +++ b/crates/hocket-core/src/audio/external.rs @@ -331,6 +331,20 @@ mod tests { assert!(matches!(&reps[2], BackendReport::Position { key, .. } if key == "b")); } + #[test] + fn transition_without_ended_still_promotes_next() { + // Media3's auto-advance: only the transition is reported. + let (b, _, reps) = harness(); + b.load(source("a"), Some(source("b")), 0, true).unwrap(); + b.report(BackendReport::TransitionedToNext { key: "b".into() }); + b.report(BackendReport::Ended { key: "b".into() }); + b.report(BackendReport::Ended { key: "a".into() }); + assert_eq!(b.current_key().as_deref(), Some("b")); + let reps = reps.lock(); + assert_eq!(reps.len(), 2, "a late Ended for the played item is stale"); + assert!(matches!(&reps[1], BackendReport::Ended { key } if key == "b")); + } + #[test] fn prebuffer_ready_only_for_the_live_prebuffer() { let (b, _, reps) = harness(); diff --git a/crates/hocket-core/src/audio/scripted.rs b/crates/hocket-core/src/audio/scripted.rs index a4ca535..b2bdabe 100644 --- a/crates/hocket-core/src/audio/scripted.rs +++ b/crates/hocket-core/src/audio/scripted.rs @@ -105,6 +105,9 @@ struct State { load_started_ms: f64, log: Vec, devices: Vec, + /// Report a gapless boundary the way Media3 does: `TransitionedToNext` + /// alone, with no `Ended` for the item that finished. + transition_only: bool, } /// See the module docs. @@ -153,6 +156,13 @@ impl ScriptedBackend { self.state.lock().load_latency_ms = ms.max(0.0); } + /// Report gapless boundaries as `TransitionedToNext` without the + /// preceding `Ended` (what an ExoPlayer auto-advance looks like when the + /// platform does not synthesise the `Ended`). + pub fn set_transition_only(&self, on: bool) { + self.state.lock().transition_only = on; + } + /// The device list [`PlaybackBackend::output_devices`] returns. pub fn set_devices(&self, devices: Vec) { self.state.lock().devices = devices.clone(); @@ -254,7 +264,12 @@ impl ScriptedBackend { break; } let ended_key = cur.source.key.clone(); - out.push(BackendReport::Ended { key: ended_key }); + let has_next = st.next.as_ref().is_some_and(|n| { + !st.failing.contains(&n.key) && !st.failing_tracks.contains(&n.track.id) + }); + if !(st.transition_only && has_next) { + out.push(BackendReport::Ended { key: ended_key }); + } match st.next.take() { Some(next) if !st.failing.contains(&next.key) diff --git a/crates/hocket-core/src/core/handlers/playback.rs b/crates/hocket-core/src/core/handlers/playback.rs index f7dd044..95df5d3 100644 --- a/crates/hocket-core/src/core/handlers/playback.rs +++ b/crates/hocket-core/src/core/handlers/playback.rs @@ -253,6 +253,7 @@ impl Actor { } } self.playback.playing = play; + self.playback.focus_suspended = false; self.playback.buffering = !transitioned; self.playback.started_at = started_at; self.playback.started_local = now - f64::from(played_ms); @@ -544,9 +545,17 @@ impl Actor { } pub(crate) fn set_playing(&mut self, playing: bool) { + if !playing && self.playback.loaded && self.playback.focus_suspended { + // Paused while focus is away: the backend must not resume later. + self.playback.focus_suspended = false; + if let Err(e) = self.backend.pause() { + self.log("debug", format!("pause: {e}")); + } + } if !self.playback.loaded || self.playback.playing == playing { return; } + self.playback.focus_suspended = false; let now = self.now(); self.playback.position_ms = self.playback.position_now(now); self.playback.position_at = now; @@ -632,6 +641,21 @@ impl Actor { pub(crate) fn on_backend_report(&mut self, report: BackendReport) { let current = |k: &QueueKey, me: &Actor| me.playback.backend_key.as_ref() == Some(k); + if let BackendReport::TransitionedToNext { key } = &report { + // A backend that moved on to the preloaded item without reporting + // `Ended` for the one that finished (Media3's auto-advance): the + // end is implied. Without it the document never advances, the + // new item's reports are dropped as foreign, and playback stops + // for good when it ends. + let implied_end = !self.playback.awaiting_transition + && !current(key, self) + && self.playback.next.as_ref().is_some_and(|n| &n.key == key); + if implied_end { + if let Some(ended) = self.playback.backend_key.clone() { + self.on_backend_report(BackendReport::Ended { key: ended }); + } + } + } match report { BackendReport::Ready { key, duration_ms } => { if !current(&key, self) { @@ -656,6 +680,7 @@ impl Actor { self.playback.position_ms = position_ms; self.playback.position_at = now; self.playback.buffering = false; + self.playback.focus_suspended = false; if !self.playback.playing { self.playback.playing = true; let actions = self.scrobbler.set_playing(true); @@ -850,12 +875,28 @@ impl Actor { self.engine_input(Input::PreBufferReady { key }); } BackendReport::AudioFocusLost { transient } => { - if self.playback.playing { - if !transient { - self.playback.want_playing = false; - } + if !self.playback.playing { + return; + } + if !transient { + self.playback.want_playing = false; self.set_playing(false); + return; } + // A call, a navigation prompt: the platform player keeps its + // intent to play and resumes by itself when focus returns. + // Pausing it here would leave it paused for good, so only + // record that nothing is audible. + let now = self.now(); + self.playback.position_ms = self.playback.position_now(now); + self.playback.position_at = now; + self.playback.playing = false; + self.playback.focus_suspended = true; + let actions = self.scrobbler.set_playing(false); + self.apply_scrobble_actions(actions); + self.save_position(); + self.stamp(); + self.emit_transport(); } BackendReport::OutputDevicesChanged { devices } => { self.output_devices = devices.clone(); diff --git a/crates/hocket-core/src/core/state.rs b/crates/hocket-core/src/core/state.rs index 0030b7f..c3bb36b 100644 --- a/crates/hocket-core/src/core/state.rs +++ b/crates/hocket-core/src/core/state.rs @@ -80,6 +80,9 @@ pub(crate) struct Playback { pub fade_gain: Option, /// Waiting for a gapless `TransitionedToNext` for this doc key. pub awaiting_transition: bool, + /// Silenced by a transient audio-focus loss: the backend still means to + /// play and resumes on its own (reporting `Playing`) when focus returns. + pub focus_suspended: bool, } impl Playback { diff --git a/crates/hocket-core/tests/actor_playback.rs b/crates/hocket-core/tests/actor_playback.rs index 1627c95..f80e7ef 100644 --- a/crates/hocket-core/tests/actor_playback.rs +++ b/crates/hocket-core/tests/actor_playback.rs @@ -386,3 +386,123 @@ async fn a_play_with_scrobbling_off_is_recorded_but_not_marked_scrobbled() { other => panic!("{other:?}"), } } + +/// A gapless auto-advance must move the session to the item the backend is +/// now playing, whether or not the backend reports `Ended` for the finished +/// one first (Media3 reports only the transition), and playback must carry on +/// through the queue rather than stop after the second item. +async fn auto_advance_follows_the_backend(name: &str, transition_only: bool) { + let t = synced_core(name).await; + t.backend.set_transition_only(transition_only); + let sid = t.server_id.clone(); + t.run(Command::PlayTracks { + server_id: sid, + track_ids: vec!["t0".into(), "t1".into(), "t2".into(), "t3".into()], + start_index: 0, + label: "Sel".into(), + shuffle: false, + }) + .await; + t.run_for(500.0).await; + assert_eq!(t.current_track_id().await.as_deref(), Some("t0")); + t.backend.clear_log(); + + t.run_for(201_000.0).await; + assert_eq!(t.current_track_id().await.as_deref(), Some("t1")); + // Gapless: the backend was not reloaded, the session followed it. + assert!(!t + .backend + .log() + .iter() + .any(|c| matches!(c, ScriptedCall::Load { .. }))); + let snap = t.snapshot().await; + assert_eq!( + snap.media_session.metadata.as_ref().unwrap().title, + "Track 1" + ); + assert!(snap.transport.position.is_playing); + assert!( + snap.transport.position.position_ms < 5_000, + "position restarted with the new item: {}", + snap.transport.position.position_ms + ); + + t.run_for(200_000.0).await; + assert_eq!(t.current_track_id().await.as_deref(), Some("t2")); + assert!( + t.backend.is_playing(), + "playback carries on past the second item" + ); + assert!(!t + .backend + .log() + .iter() + .any(|c| matches!(c, ScriptedCall::Load { .. }))); + + // Next moves on to what follows; it does not restart the playing item. + t.run(Command::Next).await; + t.run_for(500.0).await; + assert_eq!(t.current_track_id().await.as_deref(), Some("t3")); + assert_eq!( + t.snapshot().await.media_session.metadata.unwrap().title, + "Track 3" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn gapless_auto_advance_with_ended_moves_the_session() { + auto_advance_follows_the_backend("g", false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn gapless_auto_advance_without_ended_moves_the_session() { + auto_advance_follows_the_backend("h", true).await; +} + +/// A transient focus loss (a call, a navigation prompt) must not pause the +/// platform player: it resumes by itself when focus returns and reports +/// `Playing`. A pause the user asks for meanwhile does reach it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn transient_focus_loss_resumes_when_focus_returns() { + let t = synced_core("i").await; + let sid = t.server_id.clone(); + t.run(Command::PlayTracks { + server_id: sid, + track_ids: vec!["t0".into(), "t1".into()], + start_index: 0, + label: "Sel".into(), + shuffle: false, + }) + .await; + t.run_for(1_500.0).await; + t.backend.clear_log(); + t.run(Command::BackendReport { + report: BackendReport::AudioFocusLost { transient: true }, + }) + .await; + t.run_for(100.0).await; + assert!(!t.transport().await.position.is_playing, "shown as paused"); + assert!( + !t.backend.log().contains(&ScriptedCall::Pause), + "the platform player is not paused" + ); + let (key, position_ms) = t.backend.current().unwrap(); + t.run(Command::BackendReport { + report: BackendReport::Playing { key, position_ms }, + }) + .await; + t.run_for(100.0).await; + assert!(t.transport().await.position.is_playing, "focus came back"); + + t.run(Command::BackendReport { + report: BackendReport::AudioFocusLost { transient: true }, + }) + .await; + t.run(Command::Pause).await; + t.run_for(100.0).await; + assert!( + t.backend.log().contains(&ScriptedCall::Pause), + "a user pause during the loss reaches the player" + ); + assert!(!t.backend.is_playing()); +} From 39048fb565bfaa5cb5a4e5cc7934eec969919cb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:14:13 +0000 Subject: [PATCH 02/15] android: media session registered with the service; wake/Wi-Fi lock, Ended on auto-advance, network error recovery - The MediaSession was only ever returned from onGetSession, which runs when a Media3 controller connects. The UI binds for the core instead, so the session was never added to the service: no media notification, no lock-screen or quick-settings controls, and no foreground promotion, so the backgrounded process could be killed mid-song. It is added in onCreate. - ExoPlayer ran without a wake mode: with the screen off the CPU and Wi-Fi could sleep under a stream. C.WAKE_MODE_NETWORK. - An auto-advance reported only TransitionedToNext; it now reports the played item's Ended first, as the contract and the native backend do. - Every ExoPlayer error leaves it idle, so a "non-fatal" network error stopped playback for good. Connection failures/timeouts are retried with prepare() on a backoff (about a minute), then reported fatal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY --- android/README.md | 12 ++- .../java/app/hocket/playback/ExoBackend.kt | 84 +++++++++++++++---- .../app/hocket/playback/PlaybackService.kt | 8 +- .../app/hocket/playback/ExoBackendTest.kt | 32 +++++++ .../hocket/playback/ServiceLifecycleTest.kt | 16 ++++ 5 files changed, 133 insertions(+), 19 deletions(-) diff --git a/android/README.md b/android/README.md index 0624fec..2f4f01a 100644 --- a/android/README.md +++ b/android/README.md @@ -59,7 +59,10 @@ the one core for the process through `CoreHost`: - `Event.Backend(BackendCommand)` -> `ExoBackend` -> ExoPlayer. `Load` builds a per-item media source through `DefaultMediaSourceFactory` over `CoreStreamDataSourceFactory` (each item carries its own headers) plus the gapless follow-up as a second playlist item; `SetNext` replaces everything after the current item; the transition is - detected from `onMediaItemTransition(AUTO)` and the played item removed. `PreBuffer` prepares a + detected from `onMediaItemTransition(AUTO)`, reported as `Ended` (played item) then + `TransitionedToNext`, and the played item removed. The player holds `C.WAKE_MODE_NETWORK` (wake + + Wi-Fi lock) so streams keep going with the screen off. Network errors (connection failed/timeout) + are retried with `prepare()` on a ~1 min backoff and reported non-fatal; only then fatal. `PreBuffer` prepares a second silent ExoPlayer at the requested position (`PreBufferReady` when READY); `DiscardPreBuffer` releases it. `gain_db` is applied as `10^(gain/20) * masterVolume` clamped to 1.0 — Media3 has no gain stage, so positive gain is an approximation (documented in `ExoBackend`). @@ -76,13 +79,16 @@ the one core for the process through `CoreHost`: is no `CacheDataSource` (the core caches). `NativeCore` implements the `CoreStreams` seam. - Reports back: `Ready`, `Playing`, `Paused`, `Buffering`, `Position` every 750 ms while playing and on every seek/transition, `Ended`, `TransitionedToNext`, `Error`, `PreBufferReady`, - `AudioFocusLost` (transient when Media3 suppresses rather than pauses). + `AudioFocusLost` (transient when Media3 suppresses rather than pauses; the core then leaves the + player alone so it resumes when focus returns). - `Event.MediaSession(state)` -> `MediaSessionBridge` -> `CoreSessionPlayer`, a `SimpleBasePlayer` whose state *is* the core's session state (metadata, extrapolated position from the stamp, shuffle/repeat, available commands from the customised action list). Custom buttons (love, shuffle, repeat, rate) are media button preferences. Controls map to `Command.MediaSessionCommand`. Notification, lockscreen, Bluetooth and headset controls come from - Media3. Playback resumption from the system is refused (resuming is always explicit). + Media3; the service `addSession`s the session in `onCreate` (the UI is not a Media3 controller, so + `onGetSession` alone would never register it and no notification or foreground promotion would + happen). Playback resumption from the system is refused (resuming is always explicit). - `NetworkMonitor` -> `SetNetworkState` (kind, metered, hashed SSID or transport id); `BatterySaverMonitor` -> `SetBatterySaver` while `battery.autoSaver` is on. - The app binds with `ACTION_BIND_CORE` (plus a per-process token, since the service is exported diff --git a/android/playback/src/main/java/app/hocket/playback/ExoBackend.kt b/android/playback/src/main/java/app/hocket/playback/ExoBackend.kt index 6aefc12..51136e1 100644 --- a/android/playback/src/main/java/app/hocket/playback/ExoBackend.kt +++ b/android/playback/src/main/java/app/hocket/playback/ExoBackend.kt @@ -47,7 +47,9 @@ import kotlin.math.pow * sources are read through the core ([HocketStreamDataSource], which the core caches); anything * else (`file:`, a direct server URL) through `DefaultDataSource`. * - `SetNext` replaces everything after the current item. - * - `TransitionedToNext` is detected from `onMediaItemTransition(reason = AUTO)`; the played item is + * - `TransitionedToNext` is detected from `onMediaItemTransition(reason = AUTO)`, preceded by the + * `Ended` of the played item (ExoPlayer reports no end for an item it advanced past, and the + * contract is `Ended` then `TransitionedToNext`, as the native backend does); the played item is * then removed so the playlist is always "current (+ next)". * - `PreBuffer` uses a second, silent ExoPlayer prepared at the requested position; `DiscardPreBuffer` * releases its media. A subsequent `Load` of the same key still goes through the main player: the @@ -56,7 +58,13 @@ import kotlin.math.pow * no gain stage, so positive gain is an approximation (it cannot amplify) and ReplayGain-style * attenuation is exact. * - Audio focus and becoming-noisy are handled by ExoPlayer; focus loss is reported as - * `AudioFocusLost` (transient when Media3 reports a suppression reason instead of a pause). + * `AudioFocusLost` (transient when Media3 reports a suppression reason instead of a pause: ExoPlayer + * then resumes by itself when focus returns and reports `Playing`). + * - The player holds a wake lock and a Wi-Fi lock while playing ([C.WAKE_MODE_NETWORK]): without + * them a stream stalls once the screen is off and the CPU or Wi-Fi radio sleeps. + * - Network failures leave ExoPlayer idle with an error, so they are retried here with backoff + * ([recoveryDelayMs]; `prepare()` resumes at the same position) and reported as non-fatal; only when + * the retries run out is the error fatal and the core's own retry/skip takes over. */ class ExoBackend( private val context: Context, @@ -65,14 +73,24 @@ class ExoBackend( /** The running core's stream reader, read on each open (a restarted service has a new core). */ private val streams: () -> CoreStreams? = { CoreHost.current as? CoreStreams }, ) { - private companion object { - const val TAG = "ExoBackend" - const val POSITION_INTERVAL_MS = 750L + internal companion object { + private const val TAG = "ExoBackend" + private const val POSITION_INTERVAL_MS = 750L + private val RECOVERY_DELAYS_MS = longArrayOf(1_000, 2_000, 4_000, 8_000, 15_000, 30_000) + + /** Backoff before recovery attempt [attempt] (0-based) of a network error, or null when out of attempts. */ + internal fun recoveryDelayMs(attempt: Int): Long? = RECOVERY_DELAYS_MS.getOrNull(attempt) + + /** Errors a later `prepare()` can fix: the connection, not the media. */ + internal fun isRecoverable(errorCode: Int): Boolean = + errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED || + errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT } val player: ExoPlayer = ExoPlayer.Builder(context) .setAudioAttributes(AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).build(), true) .setHandleAudioBecomingNoisy(true) + .setWakeMode(C.WAKE_MODE_NETWORK) .setLoadControl(DefaultLoadControl.Builder().setBufferDurationsMs(30_000, 120_000, 2_500, 5_000).build()) .setMediaSourceFactory(mediaSourceFactory()) .build() @@ -82,6 +100,9 @@ class ExoBackend( private var masterVolume = 1.0 private var currentGainDb = 0.0 private var positionJob: Job? = null + private var recoveryJob: Job? = null + /** Recovery attempts for the current error streak; reset once the player is ready again. */ + private var recoveryAttempts = 0 /** The key of the last `Load`, so an error raised after the playlist emptied is still attributable. */ private var lastLoadedKey: String? = null /** Keys by media id, so reports name the queue key the core gave us. */ @@ -96,6 +117,7 @@ class ExoBackend( val key = currentKey() ?: return when (playbackState) { Player.STATE_READY -> { + recoveryAttempts = 0 val duration = player.duration.takeIf { it != C.TIME_UNSET }?.toUInt() report(BackendReport.Ready(BackendReportReadyInner(key, duration))) report(BackendReport.Buffering(BackendReportBufferingInner(key, false))) @@ -140,13 +162,7 @@ class ExoBackend( } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO && mediaItem != null) { - // The preloaded follow-up is now current: drop the played one so the playlist stays current(+next). - if (player.currentMediaItemIndex > 0) player.removeMediaItem(0) - applyGain(gainFor(mediaItem)) - report(BackendReport.TransitionedToNext(BackendReportTransitionedToNextInner(mediaItem.mediaId))) - report(BackendReport.Position(BackendReportPositionInner(mediaItem.mediaId, 0u))) - } + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO && mediaItem != null) onAutoTransition(mediaItem) } override fun onPositionDiscontinuity(oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int) { @@ -158,9 +174,25 @@ class ExoBackend( override fun onPlayerError(error: PlaybackException) { // No key at all (error after Stop): the core could not correlate the report, skip it. val key = errorKey() ?: return - val fatal = error.errorCode != PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED && - error.errorCode != PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT - report(BackendReport.Error(BackendReportErrorInner(key, error.errorCodeName + ": " + (error.message ?: ""), fatal))) + val message = error.errorCodeName + ": " + (error.message ?: "") + // Any player error leaves ExoPlayer idle: "non-fatal" only holds if we bring it back. + val delayMs = if (isRecoverable(error.errorCode)) recoveryDelayMs(recoveryAttempts) else null + if (delayMs == null) { + recoveryAttempts = 0 + report(BackendReport.Error(BackendReportErrorInner(key, message, true))) + return + } + recoveryAttempts++ + report(BackendReport.Error(BackendReportErrorInner(key, message, false))) + report(BackendReport.Buffering(BackendReportBufferingInner(key, true))) + recoveryJob?.cancel() + recoveryJob = scope.launch { + delay(delayMs) + if (player.playerError != null && currentKey() == key) { + Log.i(TAG, "retrying $key after ${error.errorCodeName} (attempt $recoveryAttempts)") + player.prepare() + } + } } }) } @@ -171,6 +203,7 @@ class ExoBackend( val d = command.data val sources = listOfNotNull(mediaSource(d.source), d.next?.let(::mediaSource)) lastLoadedKey = d.source.key + cancelRecovery() player.setMediaSources(sources, 0, d.position_ms.toLong()) applyGain(d.source.gainDb) player.playWhenReady = d.play @@ -191,6 +224,7 @@ class ExoBackend( BackendCommand.Pause -> player.pause() BackendCommand.Stop -> { stopPositionLoop() + cancelRecovery() lastLoadedKey = null player.stop() player.clearMediaItems() @@ -231,8 +265,28 @@ class ExoBackend( /** Current position, or the pre-buffered one when the main player has nothing. */ fun position(): UInt = player.currentPosition.coerceAtLeast(0).toUInt() + /** + * ExoPlayer moved on to the preloaded follow-up by itself. Reports the played item's `Ended` + * first, then the transition, and drops the played item so the playlist stays current(+next). + */ + internal fun onAutoTransition(mediaItem: MediaItem) { + val played = if (player.currentMediaItemIndex > 0) player.getMediaItemAt(0).mediaId else null + if (player.currentMediaItemIndex > 0) player.removeMediaItem(0) + applyGain(gainFor(mediaItem)) + if (played != null && played != mediaItem.mediaId) report(BackendReport.Ended(BackendReportEndedInner(played))) + report(BackendReport.TransitionedToNext(BackendReportTransitionedToNextInner(mediaItem.mediaId))) + report(BackendReport.Position(BackendReportPositionInner(mediaItem.mediaId, 0u))) + } + + private fun cancelRecovery() { + recoveryJob?.cancel() + recoveryJob = null + recoveryAttempts = 0 + } + fun release() { stopPositionLoop() + cancelRecovery() preBufferPlayer?.release() preBufferPlayer = null player.release() diff --git a/android/playback/src/main/java/app/hocket/playback/PlaybackService.kt b/android/playback/src/main/java/app/hocket/playback/PlaybackService.kt index e3a65ec..f94ffc2 100644 --- a/android/playback/src/main/java/app/hocket/playback/PlaybackService.kt +++ b/android/playback/src/main/java/app/hocket/playback/PlaybackService.kt @@ -29,7 +29,12 @@ import kotlinx.coroutines.launch * `Event.Backend` -> [ExoBackend], `Event.MediaSession` -> [MediaSessionBridge]. * - Registers the [NetworkMonitor] and [BatterySaverMonitor]. * - Stays a foreground service (type `mediaPlayback`) while the media session says something is - * playing; Media3 handles the notification and foreground promotion. When nothing has played for + * playing; Media3 handles the notification and foreground promotion. That only happens for a + * session the service knows about: Media3 adds one when a controller connects through + * [onGetSession], but the UI binds for the core, not as a controller, so the session is added + * explicitly in [onCreate]. Without that there was no notification, no lock-screen or quick-settings + * controls, and no foreground promotion, so a backgrounded process could be killed mid-song. + * When nothing has played for * [IDLE_TIMEOUT_MS] and no UI client is bound, it stops itself. * - Exposes a [LocalBinder] so the app process can obtain the [CoreHandle] by binding. Only the * app's own bind counts as a UI client: the service is exported for Media3, so the bind intent @@ -76,6 +81,7 @@ class PlaybackService : MediaSessionService() { backend = ExoBackend(this, scope, ::dispatch) val launch = packageManager.getLaunchIntentForPackage(packageName) bridge = MediaSessionBridge(this, CoreSessionPlayer(Looper.getMainLooper(), ::dispatch), ::dispatch, launch) + addSession(bridge.session) network = NetworkMonitor(this, ::dispatch) battery = BatterySaverMonitor(this, ::dispatch) // Subscribed before the snapshot is requested, so its `Started` cannot be missed. diff --git a/android/playback/src/test/java/app/hocket/playback/ExoBackendTest.kt b/android/playback/src/test/java/app/hocket/playback/ExoBackendTest.kt index 24150cb..e9fb2a5 100644 --- a/android/playback/src/test/java/app/hocket/playback/ExoBackendTest.kt +++ b/android/playback/src/test/java/app/hocket/playback/ExoBackendTest.kt @@ -6,6 +6,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import app.hocket.core.api.BackendCommand import app.hocket.core.api.BackendCommandLoadInner import app.hocket.core.api.BackendCommandSetNextInner +import app.hocket.core.api.BackendReport import app.hocket.core.api.Command import app.hocket.core.api.MediaSource import app.hocket.core.api.OfflineState @@ -15,8 +16,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import org.junit.After +import androidx.media3.common.PlaybackException import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -62,4 +66,32 @@ class ExoBackendTest { backend.handle(BackendCommand.SetNext(BackendCommandSetNextInner(source("c")))) assertEquals(0, backend.player.mediaItemCount) } + + private fun reports() = dispatched.filterIsInstance().map { it.data.report } + + @Test + fun autoTransitionEndsThePlayedItemBeforeTheTransition() { + backend.handle(BackendCommand.Load(BackendCommandLoadInner(source("a"), source("b"), 0u, false))) + // Stand-in for ExoPlayer's own advance: the follow-up becomes current, then the AUTO callback. + backend.player.seekToNextMediaItem() + dispatched.clear() + backend.onAutoTransition(backend.player.currentMediaItem!!) + val reports = reports() + assertEquals(BackendReport.Ended(app.hocket.core.api.BackendReportEndedInner("a")), reports[0]) + assertEquals(BackendReport.TransitionedToNext(app.hocket.core.api.BackendReportTransitionedToNextInner("b")), reports[1]) + assertEquals("the played item is dropped: current(+next)", 1, backend.player.mediaItemCount) + assertEquals("b", backend.player.currentMediaItem?.mediaId) + } + + @Test + fun networkErrorsAreRetriedWithBackoffThenGiveUp() { + assertTrue(ExoBackend.isRecoverable(PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED)) + assertTrue(ExoBackend.isRecoverable(PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT)) + assertFalse(ExoBackend.isRecoverable(PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND)) + assertFalse(ExoBackend.isRecoverable(PlaybackException.ERROR_CODE_DECODING_FAILED)) + val delays = generateSequence(0) { it + 1 }.map { ExoBackend.recoveryDelayMs(it) }.takeWhile { it != null }.toList() + assertTrue("backs off", delays.zipWithNext().all { (a, b) -> a!! < b!! }) + assertTrue("keeps trying for about a minute", delays.sumOf { it!! } in 45_000L..120_000L) + assertNull(ExoBackend.recoveryDelayMs(delays.size)) + } } diff --git a/android/playback/src/test/java/app/hocket/playback/ServiceLifecycleTest.kt b/android/playback/src/test/java/app/hocket/playback/ServiceLifecycleTest.kt index db324c7..1e59f6f 100644 --- a/android/playback/src/test/java/app/hocket/playback/ServiceLifecycleTest.kt +++ b/android/playback/src/test/java/app/hocket/playback/ServiceLifecycleTest.kt @@ -70,4 +70,20 @@ class ServiceLifecycleTest { assertFalse(own.filterEquals(guessed)) assertTrue(own.filterEquals(PlaybackServiceConnection.bindIntent(app))) } + + @Test + fun theMediaSessionIsAddedToTheServiceOnCreate() { + // The UI binds for the core, never as a Media3 controller, so without an explicit addSession + // Media3 never posts the media notification or promotes the service to the foreground. + CoreHost.forceFake = true + val controller = org.robolectric.Robolectric.buildService(PlaybackService::class.java).create() + try { + val service = controller.get() + assertEquals(1, service.sessions.size) + assertTrue(service.isSessionAdded(service.sessions.single())) + } finally { + controller.destroy() + CoreHost.forceFake = false + } + } } From 1cc752a84976349504e4707dc1cd6c8f95290abc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:14:14 +0000 Subject: [PATCH 03/15] android: holding the bottom bar no longer opens its editor The long-press shortcut (and its per-item TalkBack "Customise bottom bar" action) is gone; the editor stays in Settings > Customise and behind the account sheet's "Customise bottom bar". Tests open it from the account sheet and check that a long press on the bar does nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY --- .../main/java/app/hocket/ui/nav/AppRoot.kt | 15 ++-------- .../main/java/app/hocket/ui/nav/BottomBar.kt | 30 ++----------------- .../app/hocket/ui/NavBarCustomisationTest.kt | 28 ++++++++++++----- 3 files changed, 26 insertions(+), 47 deletions(-) diff --git a/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt b/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt index b2050bd..dee743e 100644 --- a/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt +++ b/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt @@ -73,11 +73,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.zIndex import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.semantics.CustomAccessibilityAction -import androidx.compose.ui.semantics.customActions -import androidx.compose.ui.semantics.semantics import kotlin.math.roundToInt import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -328,7 +323,6 @@ private fun MainShell(snackbar: SnackbarHostState) { val selected = selectedPlace(backStack, items, rootEntryId) val scope = rememberCoroutineScope() val density = LocalDensity.current - val haptics = LocalHapticFeedback.current var accountOpen by rememberSaveable { mutableStateOf(false) } var editorOpen by rememberSaveable { mutableStateOf(false) } var devicesOpen by rememberSaveable { mutableStateOf(false) } @@ -371,8 +365,6 @@ private fun MainShell(snackbar: SnackbarHostState) { } } val shell = remember(items, nav) { ShellNavigator(items, goTo = go, openAccount = { accountOpen = true }, openBarEditor = { editorOpen = true }, openAvailableOffline = { nav.navigate(Route.AvailableOffline) { launchSingleTop = true } }) } - val editLabel = stringResource(R.string.bottom_bar_edit) - val openEditor = remember { { haptics.performHapticFeedback(HapticFeedbackType.LongPress); editorOpen = true } } val bottomInset = with(density) { bottomInsetPx.toDp() } CompositionLocalProvider(LocalShellNavigator provides shell, LocalTabReselected provides reselected, LocalBottomBarInset provides bottomInset) { Row(Modifier.fillMaxSize().then(hiddenWhenCovered)) { @@ -433,8 +425,8 @@ private fun MainShell(snackbar: SnackbarHostState) { if (!wide) { // Drawn above the sheet so the collapsed sheet body never covers it; slides out as the // sheet expands and the full player takes the screen (read in the placement phase, so - // dragging the sheet does not recompose the bar). A long press opens its editor (also - // a TalkBack action on every item). + // dragging the sheet does not recompose the bar). Holding it does nothing special: the + // editor lives in Settings > Customise and the account sheet. ShortNavigationBar( // Transparent over the scrim (Navic's detached style). containerColor = Color.Transparent, @@ -445,7 +437,6 @@ private fun MainShell(snackbar: SnackbarHostState) { .offset { IntOffset(0, (sheet.progress * navBarHeightPx).roundToInt()) } // Its icons fade quickly as the player grows over them. .graphicsLayer { alpha = (1f - sheet.progress * 3f).coerceIn(0f, 1f) } - .longPressToEdit(openEditor) .testTag("navBar") .then(hiddenWhenCovered), ) { @@ -453,7 +444,7 @@ private fun MainShell(snackbar: SnackbarHostState) { val isSelected = item == selected ShortNavigationBarItem( selected = isSelected, onClick = { go(item) }, icon = { Icon(item.icon(isSelected), null) }, label = { Text(item.barLabel(), maxLines = 1) }, - modifier = Modifier.testTag("navBar." + item.id).semantics { customActions = listOf(CustomAccessibilityAction(editLabel) { editorOpen = true; true }) }, + modifier = Modifier.testTag("navBar." + item.id), ) } } diff --git a/android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt b/android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt index ec3d2c3..8ec8fae 100644 --- a/android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt +++ b/android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt @@ -1,8 +1,5 @@ package app.hocket.ui.nav -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -65,8 +62,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag @@ -97,9 +92,9 @@ import kotlinx.coroutines.launch import sh.calvin.reorderable.ReorderableColumn /* - * The phone's bottom bar: which places it holds (a device-local preference), what is selected, the - * long-press shortcut to its editor, and the editor itself (Settings > Customise > Bottom bar, or - * the sheet the long-press opens). + * The phone's bottom bar: which places it holds (a device-local preference), what is selected, and + * the editor itself (Settings > Customise > Bottom bar, or the sheet the account sheet's "Customise + * bottom bar" opens). Holding the bar does nothing special: a long press is not an editor shortcut. */ /** The bar's storage for this composition; AppRoot provides one so every reader shares it. */ @@ -277,25 +272,6 @@ fun NavItem.label(): String = stringResource( @Composable fun NavItem.barLabel(): String = if (this == NavItem.RecentQueues) stringResource(R.string.nav_recent_queues_short) else label() -/** - * A long press anywhere on the bar calls [onLongPress] (the editor shortcut). It watches the - * gesture before the items do, and once it fires it swallows the rest of the gesture so the item - * under the finger does not also navigate. A tap passes through untouched. - */ -fun Modifier.longPressToEdit(onLongPress: () -> Unit): Modifier = pointerInput(onLongPress) { - awaitEachGesture { - awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) - val up = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { waitForUpOrCancellation(PointerEventPass.Initial) } - if (up == null) { - onLongPress() - do { - val event = awaitPointerEvent(PointerEventPass.Initial) - event.changes.forEach { it.consume() } - } while (event.changes.any { it.pressed }) - } - } -} - /** Saves the bar and keeps the core's `sidebar` surface in step (as the navigation items always have). */ @Composable fun rememberBarSaver(prefs: NavBarPrefs = rememberNavBarPrefs()): (List) -> Unit { diff --git a/android/app/src/test/java/app/hocket/ui/NavBarCustomisationTest.kt b/android/app/src/test/java/app/hocket/ui/NavBarCustomisationTest.kt index ce643a3..5d939ec 100644 --- a/android/app/src/test/java/app/hocket/ui/NavBarCustomisationTest.kt +++ b/android/app/src/test/java/app/hocket/ui/NavBarCustomisationTest.kt @@ -87,8 +87,10 @@ class NavBarCustomisationTest { return core } - private fun openEditorByLongPress() { - compose.onNodeWithTag("navBar").performTouchInput { longClick(center) } + /** The editor sheet, from the account sheet's "Customise bottom bar" (holding the bar does not open it). */ + private fun openEditor() { + click("account.button") + click("account.editBar") await("bottomBar.editor") } @@ -130,7 +132,7 @@ class NavBarCustomisationTest { } core.start() await("navBar") - openEditorByLongPress() + openEditor() // Add Albums, move it up once (the TalkBack action), remove Search. click("bottomBar.add") click("bottomBar.addItem.albums") @@ -158,7 +160,7 @@ class NavBarCustomisationTest { fun theBarHoldsTwoToFiveItemsAndSaysWhy() { start(InMemoryNavBarPrefs(listOf("home", "search", "library", "albums", "songs"))) compose.waitUntil(5_000) { bar().size == 5 } - openEditorByLongPress() + openEditor() click("bottomBar.add") await("bottomBar.message") assertTrue(compose.onNodeWithTag("bottomBar.message").fetchSemanticsNode().config[SemanticsProperties.Text].joinToString { it.text }.contains("at most 5")) @@ -170,7 +172,7 @@ class NavBarCustomisationTest { fun removingBelowTwoIsRefusedWithAMessage() { start(InMemoryNavBarPrefs(listOf("home", "search"))) compose.waitUntil(5_000) { bar() == listOf("home", "search") } - openEditorByLongPress() + openEditor() click("bottomBar.remove.home") await("bottomBar.message") assertTrue(compose.onNodeWithTag("bottomBar.message").fetchSemanticsNode().config[SemanticsProperties.Text].joinToString { it.text }.contains("at least 2")) @@ -248,9 +250,19 @@ class NavBarCustomisationTest { @Test fun theEditorPassesTheAccessibilityChecks() { start() - openEditorByLongPress() + openEditor() A11yChecks.assertAccessible(compose, "bottom bar editor") - // Long-press did not also navigate. - assertEquals(listOf("home"), selected()) + } + + @Test + fun holdingTheBarDoesNotOpenTheEditor() { + start() + compose.waitUntil(5_000) { bar().size == 3 } + compose.onNodeWithTag("navBar").performTouchInput { longClick(center) } + compose.waitForIdle() + assertFalse(exists("bottomBar.editor")) + // Nor does an item offer an "edit" accessibility action any more. + val actions = compose.onNodeWithTag("navBar.home").fetchSemanticsNode().config.getOrNull(SemanticsActions.CustomActions).orEmpty() + assertTrue(actions.none { it.label == "Customise bottom bar" }) } } From d6bd6ad35b4f1139ca3ea8c173d046d6fa0905be Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:14:15 +0000 Subject: [PATCH 04/15] android: Navic-style predictive back between screens Every NavHost screen now has its own predictive back pair, scrubbed by the gesture: the page being left shrinks into a card with rounded corners and a soft shadow and follows the finger away from the swipe edge, while the previous page slides in from a short parallax offset. Releasing finishes the motion, cancelling runs it back. Previously predictive back scrubbed the navigation library's default crossfade. Reduced motion keeps a scrubbed crossfade. The manifest already opts in with enableOnBackInvokedCallback. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY --- android/README.md | 6 + .../main/java/app/hocket/ui/nav/AppRoot.kt | 76 +++++----- .../java/app/hocket/ui/nav/Transitions.kt | 130 ++++++++++++++++-- .../java/app/hocket/ui/PredictiveBackTest.kt | 81 +++++++++++ 4 files changed, 248 insertions(+), 45 deletions(-) create mode 100644 android/app/src/test/java/app/hocket/ui/PredictiveBackTest.kt diff --git a/android/README.md b/android/README.md index 0624fec..8f3184d 100644 --- a/android/README.md +++ b/android/README.md @@ -153,6 +153,12 @@ absent. - Now-playing sheet: `AnchoredDraggable` with a velocity-aware fling, scrim, corners morphing from pill to square, the artwork scaling from the 48 dp thumbnail to the hero; predictive back drags it down with the gesture. +- Page navigation (`ui/nav/Transitions.kt`): pushes use a shared X axis, bar switches a short + fade-through. Predictive back (after Navic) scrubs its own transition on every screen: the page + shrinks into a rounded card that follows the finger away from the swipe edge while the previous + page slides in from a short offset; releasing finishes it, cancelling runs it back. Reduced + motion makes it a crossfade. Holding the bottom bar does nothing special: it is edited from + Settings > Customise or the account sheet. - Mini player: tap to expand, swipe left/right to skip with resistance past the threshold, a thin wavy progress line. - Hero artwork: horizontal swipe to skip (springs back); long-press toggles a whole-app dynamic diff --git a/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt b/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt index dee743e..e14ecf0 100644 --- a/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt +++ b/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt @@ -77,6 +77,8 @@ import kotlin.math.roundToInt import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -496,6 +498,14 @@ private fun easedScrim(color: Color, stops: Int = 16): Brush = Brush.verticalGra }, ) +/** + * A NavHost destination drawn inside [PredictiveBackCard], so every screen (places, details, + * settings pages) takes part in the predictive back animation the same way. + */ +private inline fun NavGraphBuilder.screen(transitions: ShellTransitionInfo, noinline content: @Composable (NavBackStackEntry) -> Unit) { + composable { entry -> PredictiveBackCard(transitions, entry.id) { content(entry) } } +} + @Composable private fun AppNavHost(nav: NavHostController, transitions: ShellTransitionInfo, modifier: Modifier) { NavHost( @@ -506,39 +516,41 @@ private fun AppNavHost(nav: NavHostController, transitions: ShellTransitionInfo, exitTransition = { transitions.exit(this) }, popEnterTransition = { transitions.popEnter(this) }, popExitTransition = { transitions.popExit(this) }, + predictivePopEnterTransition = { edge -> transitions.predictivePopEnter(edge) }, + predictivePopExitTransition = { edge -> transitions.predictivePopExit(this, edge) }, sizeTransform = null, ) { - composable { HomeScreen(nav) } - composable { entry -> LibraryScreen(nav, initialTab = entry.toRoute().tab) } - composable { SearchScreen(nav) } - composable { SettingsScreen(nav) } - composable { DownloadsScreen(nav) } - composable { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Albums) } - composable { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Artists) } - composable { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Playlists) } - composable { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Songs) } - composable { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Genres) } - composable { app.hocket.ui.screens.library.AvailableOfflineScreen(nav) } - composable { FiltersScreen(nav) } - composable { entry -> FilterBuilderScreen(nav, entry.toRoute().id) } - composable { StatsScreen(nav) } - composable { entry -> AlbumDetailScreen(nav, entry.toRoute().id) } - composable { entry -> ArtistDetailScreen(nav, entry.toRoute().id) } - composable { entry -> PlaylistDetailScreen(nav, entry.toRoute().id) } - composable { entry -> GenreDetailScreen(nav, entry.toRoute().name) } - composable { SavedQueuesScreen(nav) } - composable { AudioSettingsScreen(nav) } - composable { TranscodingSettingsScreen(nav) } - composable { ConnectSettingsScreen(nav) } - composable { CustomiseSettingsScreen(nav) } - composable { AboutScreen(nav) } - composable { AccountSettingsScreen(nav) } - composable { AppearanceSettingsScreen(nav) } - composable { PlaybackSettingsScreen(nav) } - composable { DownloadsSettingsScreen(nav) } - composable { LyricsSettingsScreen(nav) } - composable { LibrarySettingsScreen(nav) } - composable { BatterySettingsScreen(nav) } - composable { BackupSettingsScreen(nav) } + screen(transitions) { HomeScreen(nav) } + screen(transitions) { entry -> LibraryScreen(nav, initialTab = entry.toRoute().tab) } + screen(transitions) { SearchScreen(nav) } + screen(transitions) { SettingsScreen(nav) } + screen(transitions) { DownloadsScreen(nav) } + screen(transitions) { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Albums) } + screen(transitions) { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Artists) } + screen(transitions) { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Playlists) } + screen(transitions) { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Songs) } + screen(transitions) { app.hocket.ui.screens.library.LibraryListScreen(nav, app.hocket.ui.screens.library.LibraryList.Genres) } + screen(transitions) { app.hocket.ui.screens.library.AvailableOfflineScreen(nav) } + screen(transitions) { FiltersScreen(nav) } + screen(transitions) { entry -> FilterBuilderScreen(nav, entry.toRoute().id) } + screen(transitions) { StatsScreen(nav) } + screen(transitions) { entry -> AlbumDetailScreen(nav, entry.toRoute().id) } + screen(transitions) { entry -> ArtistDetailScreen(nav, entry.toRoute().id) } + screen(transitions) { entry -> PlaylistDetailScreen(nav, entry.toRoute().id) } + screen(transitions) { entry -> GenreDetailScreen(nav, entry.toRoute().name) } + screen(transitions) { SavedQueuesScreen(nav) } + screen(transitions) { AudioSettingsScreen(nav) } + screen(transitions) { TranscodingSettingsScreen(nav) } + screen(transitions) { ConnectSettingsScreen(nav) } + screen(transitions) { CustomiseSettingsScreen(nav) } + screen(transitions) { AboutScreen(nav) } + screen(transitions) { AccountSettingsScreen(nav) } + screen(transitions) { AppearanceSettingsScreen(nav) } + screen(transitions) { PlaybackSettingsScreen(nav) } + screen(transitions) { DownloadsSettingsScreen(nav) } + screen(transitions) { LyricsSettingsScreen(nav) } + screen(transitions) { LibrarySettingsScreen(nav) } + screen(transitions) { BatterySettingsScreen(nav) } + screen(transitions) { BackupSettingsScreen(nav) } } } diff --git a/android/app/src/main/java/app/hocket/ui/nav/Transitions.kt b/android/app/src/main/java/app/hocket/ui/nav/Transitions.kt index a9a26b6..fc20ebe 100644 --- a/android/app/src/main/java/app/hocket/ui/nav/Transitions.kt +++ b/android/app/src/main/java/app/hocket/ui/nav/Transitions.kt @@ -1,19 +1,35 @@ package app.hocket.ui.nav import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterExitState import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.Easing import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.navigation.NavBackStackEntry +import androidx.navigationevent.NavigationEvent import kotlin.math.roundToInt /* @@ -21,11 +37,15 @@ import kotlin.math.roundToInt * * - Pushes (a detail or settings screen) use Material's shared X axis: the new screen slides in * from ~30% of the width on an emphasized-decelerate curve while it fades in; the old one drifts - * 30 dp the other way while fading out quickly. Pops (and predictive back, which scrubs the pop - * transition) reverse it. + * 30 dp the other way while fading out quickly. Pops reverse it. + * - Predictive back (after Navic) has its own pair, scrubbed by the gesture: the screen being left + * becomes a card that shrinks, rounds its corners ([PredictiveBackCard]) and moves with the finger + * (toward the far side from the edge the swipe started at), while the screen behind slides in + * from a short parallax offset on that side. Letting go finishes the same motion (the card leaves + * the screen); cancelling runs it back. * - Switching places from the bottom bar or rail is a quick fade-through with a short slide in the * direction of the tab (right for a tab to the right), so it reads as a lateral move, not a push. - * - With reduced motion everything is a plain short crossfade. + * - With reduced motion everything is a plain short crossfade (predictive back scrubs a crossfade). */ /** Material's emphasized decelerate curve (entering elements). */ @@ -41,6 +61,16 @@ private const val TAB_EXIT_MS = 90 private const val REDUCED_MS = 150 private val ExitDrift = 30.dp +/** Predictive back: the length the gesture scrubs through (and so how quickly a release finishes it). */ +internal const val PREDICTIVE_MS = 350 +/** How small the card being left gets, and how round its corners. */ +private const val PREDICTIVE_SCALE = 0.9f +private val PredictiveCorner = 28.dp +/** The card's shrink (and corners) lead: most of it happens in the first third of the gesture. */ +private val PredictiveShrink = Easing { t -> 1f - (1f - (t * 2.5f).coerceAtMost(1f)).let { it * it } } +/** The card lags the finger at first, then leaves quickly (a release finishes the slide). */ +private val PredictiveSlide = CubicBezierEasing(0.4f, 0f, 1f, 1f) + /** * What the shell tells the transitions about the navigation it just made. Not snapshot state: it is * written right before `navigate` and read by the transition lambdas when the change is animated. @@ -55,6 +85,13 @@ internal class ShellTransitionInfo { var tabTargetId: String? = null var tabForward: Boolean = true var reducedMotion: Boolean = false + + /** + * The entry a predictive back gesture is currently dragging away (set when its transition is + * built, cleared by any other transition), so [PredictiveBackCard] only rounds that one. + */ + var predictiveExitId: String? = null + private set var layoutDirection: LayoutDirection = LayoutDirection.Ltr var density: Density = Density(1f) @@ -71,11 +108,14 @@ internal class ShellTransitionInfo { slideInHorizontally(tween(PUSH_MS, easing = EmphasizedDecelerate)) { w -> sign * (w * 0.3f).roundToInt() } } - fun exit(scope: AnimatedContentTransitionScope): ExitTransition = when { - reducedMotion -> fadeOut(tween(REDUCED_MS)) - isTabSwitch(scope) -> tabExit() - else -> fadeOut(tween(PUSH_EXIT_MS / 2 + 50, easing = LinearEasing)) + - slideOutHorizontally(tween(PUSH_EXIT_MS, easing = EmphasizedAccelerate)) { -sign * drift } + fun exit(scope: AnimatedContentTransitionScope): ExitTransition { + predictiveExitId = null + return when { + reducedMotion -> fadeOut(tween(REDUCED_MS)) + isTabSwitch(scope) -> tabExit() + else -> fadeOut(tween(PUSH_EXIT_MS / 2 + 50, easing = LinearEasing)) + + slideOutHorizontally(tween(PUSH_EXIT_MS, easing = EmphasizedAccelerate)) { -sign * drift } + } } fun popEnter(scope: AnimatedContentTransitionScope): EnterTransition = when { @@ -85,11 +125,45 @@ internal class ShellTransitionInfo { slideInHorizontally(tween(PUSH_MS, easing = EmphasizedDecelerate)) { -sign * drift } } - fun popExit(scope: AnimatedContentTransitionScope): ExitTransition = when { - reducedMotion -> fadeOut(tween(REDUCED_MS)) - isTabSwitch(scope) -> tabExit() - else -> fadeOut(tween(PUSH_EXIT_MS, easing = LinearEasing)) + - slideOutHorizontally(tween(PUSH_MS, easing = EmphasizedAccelerate)) { w -> sign * (w * 0.3f).roundToInt() } + fun popExit(scope: AnimatedContentTransitionScope): ExitTransition { + predictiveExitId = null + return when { + reducedMotion -> fadeOut(tween(REDUCED_MS)) + isTabSwitch(scope) -> tabExit() + else -> fadeOut(tween(PUSH_EXIT_MS, easing = LinearEasing)) + + slideOutHorizontally(tween(PUSH_MS, easing = EmphasizedAccelerate)) { w -> sign * (w * 0.3f).roundToInt() } + } + } + + /** + * Horizontal direction the predictive card moves in: away from the edge the swipe started at + * (right from the left edge, left from the right edge; physical, whatever the layout direction). + */ + private fun predictiveDirection(swipeEdge: Int): Int = when (swipeEdge) { + NavigationEvent.EDGE_LEFT -> 1 + NavigationEvent.EDGE_RIGHT -> -1 + else -> sign + } + + /** The screen behind, revealed by a back gesture: slides in from a short offset on the swipe's side. */ + fun predictivePopEnter(swipeEdge: Int): EnterTransition = when { + reducedMotion -> fadeIn(tween(PREDICTIVE_MS, easing = LinearEasing)) + else -> { + val dir = predictiveDirection(swipeEdge) + slideInHorizontally(tween(PREDICTIVE_MS, easing = EmphasizedDecelerate)) { w -> -dir * w / 6 } + } + } + + /** The screen a back gesture drags away: a shrinking card following the finger ([PredictiveBackCard] rounds it). */ + fun predictivePopExit(scope: AnimatedContentTransitionScope, swipeEdge: Int): ExitTransition { + if (reducedMotion) { + predictiveExitId = null + return fadeOut(tween(PREDICTIVE_MS, easing = LinearEasing)) + } + predictiveExitId = scope.initialState.id + val dir = predictiveDirection(swipeEdge) + return scaleOut(tween(PREDICTIVE_MS, easing = PredictiveShrink), targetScale = PREDICTIVE_SCALE) + + slideOutHorizontally(tween(PREDICTIVE_MS, easing = PredictiveSlide)) { w -> dir * w } } private fun tabEnter(): EnterTransition { @@ -101,3 +175,33 @@ internal class ShellTransitionInfo { // The outgoing tab just fades out fast: no ghosting of two pages over each other. private fun tabExit(): ExitTransition = fadeOut(tween(TAB_EXIT_MS, easing = LinearEasing)) } + +/** + * Wraps every screen in the NavHost. While a predictive back gesture drags this screen away + * ([ShellTransitionInfo.predictiveExitId]) it is drawn as a card: opaque, clipped to corners that + * round in as it shrinks, with a soft shadow over the screen revealed behind it. The amount follows + * the entry's own enter/exit transition, so the gesture scrubs it with everything else; otherwise + * this adds nothing (an instant, zero-length animation). + */ +@Composable +internal fun AnimatedVisibilityScope.PredictiveBackCard(transitions: ShellTransitionInfo, entryId: String, content: @Composable () -> Unit) { + val card by transition.animateFloat( + transitionSpec = { if (transitions.predictiveExitId == entryId) tween(PREDICTIVE_MS, easing = PredictiveShrink) else snap() }, + label = "predictiveBackCard", + ) { state -> if (state == EnterExitState.PostExit && transitions.predictiveExitId == entryId) 1f else 0f } + val background = MaterialTheme.colorScheme.background + Box( + Modifier + .fillMaxSize() + .graphicsLayer { + // Read here (not in composition), so scrubbing only redraws the layer. + val f = card + if (f > 0f) { + shape = RoundedCornerShape(PredictiveCorner * f) + clip = true + shadowElevation = 6.dp.toPx() * f + } + } + .drawBehind { if (card > 0f) drawRect(background) }, + ) { content() } +} diff --git a/android/app/src/test/java/app/hocket/ui/PredictiveBackTest.kt b/android/app/src/test/java/app/hocket/ui/PredictiveBackTest.kt new file mode 100644 index 0000000..4541f84 --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/PredictiveBackTest.kt @@ -0,0 +1,81 @@ +package app.hocket.ui + +import androidx.activity.BackEventCompat +import androidx.activity.ComponentActivity +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performSemanticsAction +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.ui.nav.AppRoot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Predictive back between screens: the gesture reveals the previous screen while the current one + * shrinks into a card that moves away from the swipe's edge; cancelling restores it, releasing + * finishes the pop. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35], application = android.app.Application::class, qualifiers = "w411dp-h891dp") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class PredictiveBackTest { + @get:Rule + val compose = createAndroidComposeRule() + + private fun exists(tag: String) = compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() + + private fun back(block: (androidx.activity.OnBackPressedDispatcher) -> Unit) { + compose.activityRule.scenario.onActivity { block(it.onBackPressedDispatcher) } + compose.waitForIdle() + } + + private fun event(progress: Float, edge: Int = BackEventCompat.EDGE_LEFT) = BackEventCompat(touchX = 10f + progress * 400f, touchY = 800f, progress = progress, swipeEdge = edge) + + private fun openSubPage() { + val core = TestCore(startPlaying = false) + compose.setThemedContent(core) { AppRoot(core.client) } + core.start() + compose.openSettingsFromAccount() + compose.onNodeWithTag("settings.category.appearance").performScrollTo().performSemanticsAction(SemanticsActions.OnClick) + compose.waitUntil(5_000) { exists("settings.screen") && !exists("settings.categories") } + compose.waitForIdle() + } + + @Test + fun theGestureShrinksThePageOverThePreviousOneAndCancelRestoresIt() { + openSubPage() + val full = compose.onRoot().fetchSemanticsNode().boundsInRoot + back { it.dispatchOnBackStarted(event(0f)) } + back { it.dispatchOnBackProgressed(event(0.3f)) } + // Both screens are composed: the settings list is revealed behind the sub-page. + assertTrue(exists("settings.categories")) + val card = compose.onNodeWithTag("settings.screen").fetchSemanticsNode().boundsInRoot + assertTrue("the page shrinks: $card in $full", card.width < full.width * 0.95f && card.height < full.height * 0.95f) + assertTrue("and moves right, away from the left edge: $card", card.left > full.left + 1f) + back { it.dispatchOnBackCancelled() } + compose.waitUntil(5_000) { !exists("settings.categories") } + val restored = compose.onNodeWithTag("settings.screen").fetchSemanticsNode().boundsInRoot + assertEquals(full.width, restored.width, 1f) + } + + @Test + fun fromTheRightEdgeThePageMovesLeftAndReleasingPops() { + openSubPage() + val full = compose.onRoot().fetchSemanticsNode().boundsInRoot + back { it.dispatchOnBackStarted(event(0f, BackEventCompat.EDGE_RIGHT)) } + back { it.dispatchOnBackProgressed(event(0.5f, BackEventCompat.EDGE_RIGHT)) } + val card = compose.onNodeWithTag("settings.screen").fetchSemanticsNode().boundsInRoot + assertTrue("moves left, away from the right edge: $card", card.right < full.right - 1f) + back { it.onBackPressed() } + compose.waitUntil(5_000) { exists("settings.categories") && !exists("settings.screen") } + } +} From 1dd53b9ed0bae7a58f8644a63369c1273d6853d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:22:15 +0000 Subject: [PATCH 05/15] android: one song menu, hosted at the app level, from every entry point The song menu opened from the full player was a ModalBottomSheet composed inside FullPlayer, i.e. a child of the now-playing sheet's content: its dialog's composition, theme and state hung off the moving, recomposing player sheet (the live nowPlaying entry drove its target, title and rating; the actions query landed after the sheet had started showing, so it re-anchored as it grew; its go-to and sleep-timer rows removed it with no hide animation). Every list, the queue and the player also built their own ActionSheet with different go-to rows, rating (inline stars vs a Rate dialog) and extras (a bare TextButton in playlists). SongMenu.kt is now the one song menu: header (artwork, title, artist - album), the star row, the registry's contextMenu actions in the user's order (rate entries folded into the stars, go-to shown whenever the song has an album/artist), then below a divider the context's own rows (the queue's Remove from queue, a playlist's Remove from playlist, the player's sleep timer). A SongMenuController in the main shell drives one SongMenuHost outside the player sheet; callers only call open(). The actions are fetched before the sheet shows (no re-anchoring), each opening gets a fresh sheet state, and every row hides the sheet with its animation. Screens shown on their own host a local menu. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY --- .../java/app/hocket/ui/components/SongMenu.kt | 258 ++++++++++++++++++ .../main/java/app/hocket/ui/nav/AppRoot.kt | 5 +- .../app/hocket/ui/player/NowPlayingPage.kt | 38 +-- .../java/app/hocket/ui/queue/QueuePanel.kt | 6 +- .../hocket/ui/screens/detail/DetailScreens.kt | 32 +-- .../ui/screens/library/LibraryScreen.kt | 8 +- .../hocket/ui/screens/search/SearchScreen.kt | 8 +- .../test/java/app/hocket/ui/SongMenuTest.kt | 177 ++++++++++++ 8 files changed, 472 insertions(+), 60 deletions(-) create mode 100644 android/app/src/main/java/app/hocket/ui/components/SongMenu.kt create mode 100644 android/app/src/test/java/app/hocket/ui/SongMenuTest.kt diff --git a/android/app/src/main/java/app/hocket/ui/components/SongMenu.kt b/android/app/src/main/java/app/hocket/ui/components/SongMenu.kt new file mode 100644 index 0000000..6d44a88 --- /dev/null +++ b/android/app/src/main/java/app/hocket/ui/components/SongMenu.kt @@ -0,0 +1,258 @@ +package app.hocket.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.hocket.R +import app.hocket.core.ActionIds +import app.hocket.core.ArtworkSizes +import app.hocket.core.Commands +import app.hocket.core.api.ActionDescriptor +import app.hocket.core.api.ActionTarget +import app.hocket.core.api.TrackSummary +import app.hocket.ui.DetailNavigator +import app.hocket.ui.LocalCoreClient +import app.hocket.ui.LocalDetailNavigator +import kotlinx.coroutines.launch + +/** + * A row a context adds to the song menu (the playlist's "Remove from playlist", the player's sleep + * timer). Extras always sit in one place: after the registry's actions, below a divider. + */ +@Immutable +class SongMenuExtra( + val label: String, + val icon: ImageVector, + val onClick: () -> Unit, + val supporting: String? = null, + val destructive: Boolean = false, + val highlighted: Boolean = false, + val testTag: String? = null, +) + +/** One opening of the song menu: the song, what its actions act on (queue rows pass their item), the context's extras. */ +@Immutable +data class SongMenuRequest(val track: TrackSummary, val target: ActionTarget, val extras: List = emptyList()) + +/** + * Opens THE song menu. There is one per screen tree, hosted at the app level ([SongMenuHost]) and + * driven by this state, so the menu looks and behaves the same from every list, the queue and the + * full player, and its sheet never lives inside a surface that moves or leaves composition (the + * player sheet): it is dismissed by its own gesture and nothing else. + */ +@Stable +class SongMenuController { + /** Asked for, actions not loaded yet. */ + internal var pending by mutableStateOf(null) + /** On screen, with the registry's actions it was opened with. */ + internal var shown by mutableStateOf>?>(null) + + fun open(track: TrackSummary, target: ActionTarget = Commands.tracks(listOf(track.id)), extras: List = emptyList()) { + if (shown == null) pending = SongMenuRequest(track, target, extras) + } + + val isOpen: Boolean get() = pending != null || shown != null + + internal fun close() { pending = null; shown = null } +} + +val LocalSongMenu = staticCompositionLocalOf { null } + +/** + * The song menu for this tree: the app-level one inside the main shell, or (for a screen shown on its + * own) one hosted here. + */ +@Composable +fun rememberSongMenu(): SongMenuController { + LocalSongMenu.current?.let { return it } + val local = remember { SongMenuController() } + SongMenuHost(local, LocalDetailNavigator.current) + return local +} + +/** + * Hosts [controller]'s sheet. The registry's actions are fetched before the sheet is shown, so it + * opens at its final height instead of growing (and re-anchoring) under the finger. [navigator] + * performs "Go to album / artist" (the shell's collapses the player as it navigates). + */ +@Composable +fun SongMenuHost(controller: SongMenuController, navigator: DetailNavigator?) { + val client = LocalCoreClient.current + val pending = controller.pending + LaunchedEffect(pending) { + val request = pending ?: return@LaunchedEffect + val actions = client.actions("contextMenu", request.target) + if (controller.pending == request) { + controller.pending = null + controller.shown = request to actions + } + } + var pickerFor by remember { mutableStateOf(null) } + controller.shown?.let { (request, actions) -> + // Keyed on the request: a new opening starts from a fresh sheet state, never a stale one. + androidx.compose.runtime.key(request) { + SongMenuSheet( + request, actions, navigator, + onDismiss = { controller.close() }, + onAddToPlaylist = { pickerFor = request.target }, + ) + } + } + pickerFor?.let { t -> PlaylistPicker(target = t, onDismiss = { pickerFor = null }) } +} + +/** The registry's rate entries: the menu's star row replaces them. */ +private val RATE_IDS = (0..5).map { ActionIds.rate(it) }.toSet() + ActionIds.RATE + +/** Registry actions that only apply in one context (the queue, a playlist): listed with the extras. */ +private val CONTEXT_IDS = setOf(ActionIds.REMOVE_FROM_QUEUE, ActionIds.REMOVE_FROM_PLAYLIST) + +/** The song menu's rows: the common actions, and the context's (listed last, with the extras). */ +internal data class SongMenuRows(val common: List, val context: List) + +/** + * What the song menu lists, in the registry's (user-customisable) order: the rating is the star row + * above, go-to entries only where the song has an album / artist and something can navigate, and + * context-only actions split off so they always sit in the same place, after the common ones. + */ +internal fun songMenuRows(actions: List, track: TrackSummary, canNavigate: Boolean): SongMenuRows { + val shown = actions.filter { a -> + when (a.id) { + in RATE_IDS -> false + ActionIds.GO_TO_ALBUM -> canNavigate && track.albumId != null + ActionIds.GO_TO_ARTIST -> canNavigate && track.artistId != null + else -> true + } + } + val (context, common) = shown.partition { it.id in CONTEXT_IDS } + return SongMenuRows(common, context) +} + +/** + * The song menu: artwork, title and "artist · album"; the rating as stars; the registry's actions + * for the target; then, below a divider, the context's own (remove from queue / playlist, extras). + * Every row closes the sheet with its hide animation (actions and navigation start at once, behind it). + */ +@Composable +private fun SongMenuSheet( + request: SongMenuRequest, + actions: List, + navigator: DetailNavigator?, + onDismiss: () -> Unit, + onAddToPlaylist: () -> Unit, +) { + val client = LocalCoreClient.current + val track = request.track + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + fun hideThen(after: () -> Unit = {}) { + scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss(); after() } + } + var rating by remember { mutableIntStateOf(track.rating.toInt()) } + val rows = remember(actions, track, navigator) { songMenuRows(actions, track, navigator != null) } + @Composable fun ActionRow(a: ActionDescriptor) { + MenuRow(a.label, actionIcon(a.icon), destructive = a.destructive, enabled = a.enabled, tag = "songMenu.action.${a.id}") { + when (a.id) { + ActionIds.ADD_TO_PLAYLIST -> hideThen(onAddToPlaylist) + ActionIds.GO_TO_ALBUM -> { track.albumId?.let { navigator?.openAlbum?.invoke(it) }; hideThen() } + ActionIds.GO_TO_ARTIST -> { track.artistId?.let { navigator?.openArtist?.invoke(it) }; hideThen() } + in ActionIds.UI_HANDLED -> hideThen() + else -> { client.dispatch(Commands.runAction(a.id, request.target)); hideThen() } + } + } + } + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = Modifier.testTag("songMenu")) { + Column(Modifier.navigationBarsPadding().verticalScroll(rememberScrollState())) { + Row(Modifier.fillMaxWidth().padding(horizontal = 24.dp).testTag("songMenu.header"), verticalAlignment = Alignment.CenterVertically) { + Artwork(track.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(ListArtCorner)) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text(track.title, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.semantics { heading() }) + val sub = listOfNotNull(track.artist ?: stringResource(R.string.unknown_artist), track.album).joinToString(stringResource(R.string.dot_separator)) + Text(sub, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + RatingStars( + rating, + onRate = { stars -> rating = stars; client.dispatch(Commands.runAction(ActionIds.rate(stars), request.target)) }, + starSize = 32.dp, + modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally).padding(vertical = 4.dp), + ) + HorizontalDivider() + rows.common.forEach { ActionRow(it) } + if (rows.context.isNotEmpty() || request.extras.isNotEmpty()) { + HorizontalDivider() + rows.context.forEach { ActionRow(it) } + request.extras.forEach { e -> + MenuRow(e.label, e.icon, supporting = e.supporting, destructive = e.destructive, highlighted = e.highlighted, tag = e.testTag) { hideThen(e.onClick) } + } + } + Spacer(Modifier.height(16.dp)) + } + } +} + +@Composable +private fun MenuRow( + label: String, + icon: ImageVector, + supporting: String? = null, + destructive: Boolean = false, + highlighted: Boolean = false, + enabled: Boolean = true, + tag: String? = null, + onClick: () -> Unit, +) { + val tint = when { + destructive -> MaterialTheme.colorScheme.error + highlighted -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + ListItem( + headlineContent = { Text(label, color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified) }, + supportingContent = supporting?.let { { Text(it) } }, + leadingContent = { Icon(icon, null, tint = tint) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable(enabled = enabled, onClick = onClick).let { if (tag != null) it.testTag(tag) else it }, + ) +} diff --git a/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt b/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt index b2050bd..16b6f3e 100644 --- a/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt +++ b/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt @@ -315,12 +315,14 @@ private val CollapsedRailWidth = 96.dp private fun MainShell(snackbar: SnackbarHostState) { val nav = rememberNavController() val sheet = rememberNowPlayingSheetState() + // The one song menu, hosted here (not inside the player sheet or a list) and opened from anywhere. + val songMenu = remember { app.hocket.ui.components.SongMenuController() } val client = LocalCoreClient.current val nowPlaying = client.nowPlaying.collectAsStateWithLifecycle() val playerVisible by remember { derivedStateOf { nowPlaying.value != null } } BoxWithConstraints(Modifier.fillMaxSize()) { val wide = maxWidth >= 600.dp - CompositionLocalProvider(LocalWideLayout provides wide, LocalPlayerVisible provides playerVisible) { + CompositionLocalProvider(LocalWideLayout provides wide, LocalPlayerVisible provides playerVisible, app.hocket.ui.components.LocalSongMenu provides songMenu) { val items = navItems() val backStack by nav.currentBackStack.collectAsStateWithLifecycle() // The root entry of the stack last switched to (see selectedPlace). @@ -420,6 +422,7 @@ private fun MainShell(snackbar: SnackbarHostState) { onOpenArtist = navigator.openArtist, ) } + app.hocket.ui.components.SongMenuHost(songMenu, navigator) // The scrim the bar and the mini player float on (Navic): the surface colour eased in // from transparent, so content scrolling under them fades out instead of clashing. if (!wide || playerVisible) { diff --git a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt index 335043b..8934f84 100644 --- a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt +++ b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt @@ -111,7 +111,6 @@ import app.hocket.core.api.Command import app.hocket.core.api.QueueSource import app.hocket.ui.LocalCoreClient import app.hocket.ui.a11y.LocalReducedMotion -import app.hocket.ui.components.ActionSheet import app.hocket.ui.components.Artwork import app.hocket.ui.components.PlaylistPicker import app.hocket.ui.components.formatClock @@ -123,8 +122,6 @@ import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.roundToInt import androidx.compose.ui.text.style.LineHeightStyle -import app.hocket.ui.components.RatingStars -import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.combinedClickable /** @@ -166,7 +163,9 @@ internal fun FullPlayer( val density = LocalDensity.current var handoff by remember { mutableStateOf(false) } var sleepSheet by remember { mutableStateOf(false) } - var more by remember { mutableStateOf(false) } + val songMenu = app.hocket.ui.components.rememberSongMenu() + val sleepLabel = stringResource(R.string.player_sleep_timer) + val resources = androidx.compose.ui.platform.LocalResources.current var addTo by remember { mutableStateOf(false) } hero.artInset = with(density) { PAGE_PADDING.toPx() } hero.artMax = with(density) { MAX_ART.toPx() } @@ -188,7 +187,14 @@ internal fun FullPlayer( position = position, hero = hero, pageWidth = pageWidth, - onMore = { more = true }, + onMore = { + // The song menu is hosted at the app level, not in this (moving) sheet; + // the player adds the sleep timer as its extra. + val sleep = client.sleepTimer.value + val sleepState = sleep?.let { t -> t.endsAt?.let { resources.getString(R.string.sleep_active, formatClock((it - System.currentTimeMillis()).toLong().coerceAtLeast(0))) } ?: resources.getString(R.string.sleep_active_end_of_track) } + track?.let { songMenu.open(it, extras = listOf(app.hocket.ui.components.SongMenuExtra(sleepLabel, Icons.Filled.Bedtime, + onClick = { sleepSheet = true }, supporting = sleepState, highlighted = sleep != null, testTag = "player.sleep"))) } + }, onAddTo = { addTo = true }, ) }, @@ -208,14 +214,6 @@ internal fun FullPlayer( } if (handoff) HandoffSheet(onDismiss = { handoff = false }) if (sleepSheet) SleepTimerSheet(onDismiss = { sleepSheet = false }) - if (more && track != null) ActionSheet(Commands.tracks(listOf(track.id)), track.title, track.artist, onDismiss = { more = false }, - onGoToAlbum = track.albumId?.let { id -> { onOpenAlbum(id) } }, onGoToArtist = track.artistId?.let { id -> { onOpenArtist(id) } }, - extraTop = { - // As in Navic: the rating across the top of the song menu, then the sleep timer. - RatingStars(track.rating.toInt(), onRate = { client.dispatch(Commands.rateTrack(track.id, it)) }, starSize = 32.dp, tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally).padding(vertical = 4.dp)) - SleepTimerMenuRow(onClick = { more = false; sleepSheet = true }) - }) if (addTo && track != null) PlaylistPicker(Commands.tracks(listOf(track.id)), onDismiss = { addTo = false }) } @@ -488,20 +486,6 @@ private fun ModeBar(mode: PlayerMode, onMode: (PlayerMode) -> Unit, pageWidth: D } } -/** The More sheet's sleep-timer entry: the remaining time when one is set. */ -@Composable -private fun SleepTimerMenuRow(onClick: () -> Unit) { - val client = LocalCoreClient.current - val sleep by client.sleepTimer.collectAsStateWithLifecycle() - androidx.compose.material3.ListItem( - headlineContent = { Text(stringResource(R.string.player_sleep_timer)) }, - supportingContent = sleep?.let { t -> { Text(t.endsAt?.let { stringResource(R.string.sleep_active, formatClock((it - System.currentTimeMillis()).toLong().coerceAtLeast(0))) } ?: stringResource(R.string.sleep_active_end_of_track)) } }, - leadingContent = { Icon(Icons.Filled.Bedtime, null, tint = if (sleep != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) }, - colors = androidx.compose.material3.ListItemDefaults.colors(containerColor = Color.Transparent), - modifier = Modifier.clickable(onClick = onClick).testTag("player.sleep"), - ) -} - private val PILL_PADDING = 12.dp private val PILL_ICON = 20.dp private val PILL_GAP = 6.dp diff --git a/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt b/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt index 161940c..867d01c 100644 --- a/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt +++ b/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt @@ -87,7 +87,6 @@ import app.hocket.core.api.QueueSource import app.hocket.core.api.RepeatMode import app.hocket.core.client.SelectionKind import app.hocket.ui.LocalCoreClient -import app.hocket.ui.components.ActionSheet import app.hocket.ui.components.Artwork import app.hocket.ui.components.EmptyState import app.hocket.ui.components.ListArtCorner @@ -196,7 +195,7 @@ private fun QueueTimeline(modifier: Modifier, contentPadding: PaddingValues) { val selecting = selection.active && kind == SelectionKind.QueueItems val haptics = LocalHapticFeedback.current val density = LocalDensity.current - var sheetFor by remember { mutableStateOf(null) } + val songMenu = app.hocket.ui.components.rememberSongMenu() var ratingFor by remember { mutableStateOf(null) } val historyLabel = stringResource(R.string.queue_section_history) val nowLabel = stringResource(R.string.queue_now) @@ -337,7 +336,7 @@ private fun QueueTimeline(modifier: Modifier, contentPadding: PaddingValues) { extraActions = extra, onClick = { client.dispatch(Commands.jumpToQueueItem(entry.item.key)) }, onToggleSelect = { client.toggleSelected(SelectionKind.QueueItems, entry.item.key) }, - onMore = { sheetFor = entry }, + onMore = { songMenu.open(entry.track, Commands.queueItems(listOf(entry.item.key))) }, onRate = { ratingFor = entry }, modifier = Modifier.drawBehind { if (dismiss.dismissDirection != SwipeToDismissBoxValue.Settled) drawRect(swipeSurface) }, handle = if (draggable) ({ @@ -359,7 +358,6 @@ private fun QueueTimeline(modifier: Modifier, contentPadding: PaddingValues) { } SelectionToolbar(Modifier.align(Alignment.BottomCenter)) } - sheetFor?.let { e -> ActionSheet(Commands.queueItems(listOf(e.item.key)), e.track.title, e.track.artist, onDismiss = { sheetFor = null }) } ratingFor?.let { e -> RatingDialog(current = e.track.rating.toInt(), onRate = { stars -> client.dispatch(Commands.runAction(ActionIds.rate(stars), Commands.queueItems(listOf(e.item.key)))); ratingFor = null }, onDismiss = { ratingFor = null }) } diff --git a/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt b/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt index 0417539..ebaf090 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.PlaylistRemove import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder @@ -243,7 +244,7 @@ fun AlbumDetailScreen(nav: NavHostController, id: String, embedded: Boolean = fa val selection by client.selection.collectAsStateWithLifecycle() val kind by client.selectionKind.collectAsStateWithLifecycle() val selecting = selection.active && kind == SelectionKind.Tracks - var sheetFor by remember { mutableStateOf(null) } + val songMenu = app.hocket.ui.components.rememberSongMenu() var albumSheet by remember { mutableStateOf(false) } val context = Commands.albumContext(a.serverId, a.id, a.name) DetailScaffold(nav, embedded, a.name, a.artist, a.coverArt, actions = { IconButton(onClick = { albumSheet = true }) { Icon(Icons.Filled.MoreVert, stringResource(R.string.action_more)) } }, header = { @@ -262,12 +263,11 @@ fun AlbumDetailScreen(nav: NavHostController, id: String, embedded: Boolean = fa val list = tracks if (list == null) items(8, key = { "skeleton.row$it" }, contentType = { "skeletonRow" }) { TrackRowSkeleton(showArtwork = false) } else itemsIndexed(list, key = { _, t -> t.id }, contentType = { _, _ -> "track" }) { i, t -> - TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playContext(context, startIndex = i)) }, onMore = { sheetFor = t }, showArtwork = false, + TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playContext(context, startIndex = i)) }, onMore = { songMenu.open(t.toSummary()) }, showArtwork = false, leading = { Text((t.trackNumber?.toInt() ?: (i + 1)).toString(), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.width(32.dp)) }, selected = selecting && selection.contains(t.id), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.Tracks, t.id) }, nowPlaying = nowPlaying?.track?.id == t.id) } } - sheetFor?.let { t -> ActionSheet(Commands.tracks(listOf(t.id)), t.title, t.artist, onDismiss = { sheetFor = null }, onGoToArtist = t.artistId?.let { aid -> { nav.navigate(Route.Artist(aid)) } }) } if (albumSheet) ActionSheet(Commands.albums(listOf(a.id)), a.name, a.artist, onDismiss = { albumSheet = false }, onGoToArtist = a.artistId?.let { aid -> { nav.navigate(Route.Artist(aid)) } }) } @@ -302,7 +302,7 @@ fun ArtistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = f return } val context = Commands.artistContext(ar.serverId, ar.id, ar.name) - var sheetFor by remember { mutableStateOf(null) } + val songMenu = app.hocket.ui.components.rememberSongMenu() DetailScaffold(nav, embedded, ar.name, stringResource(R.string.library_count_albums, ar.albumCount.toInt()), ar.coverArt, roundArtwork = true, header = { IconToggleButton(checked = ar.loved, onCheckedChange = { client.dispatch(Commands.setArtistLoved(ar.id, it)) }) { Icon(if (ar.loved) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, stringResource(if (ar.loved) R.string.action_unlove else R.string.action_love), tint = if (ar.loved) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant) @@ -313,7 +313,7 @@ fun ArtistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = f if (top.isNotEmpty()) { item(key = "h.top") { SectionHeader(stringResource(R.string.artist_top_songs)) } itemsIndexed(top, key = { _, t -> "top" + t.id }, contentType = { _, _ -> "track" }) { i, t -> - TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playTracks(t.serverId, top.map { it.id }, i, ar.name)) }, onMore = { sheetFor = t }) + TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playTracks(t.serverId, top.map { it.id }, i, ar.name)) }, onMore = { songMenu.open(t.toSummary()) }) } } if (albums.isNotEmpty()) { @@ -321,7 +321,6 @@ fun ArtistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = f item(key = "albums") { AlbumStrip(albums) { nav.navigate(Route.Album(it.id)) } } } } - sheetFor?.let { t -> ActionSheet(Commands.tracks(listOf(t.id)), t.title, t.artist, onDismiss = { sheetFor = null }, onGoToAlbum = t.albumId?.let { aid -> { nav.navigate(Route.Album(aid)) } }) } } /** One playlist row: its track and a key that stays with it while the list is reordered. */ @@ -364,7 +363,8 @@ fun PlaylistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = val selection by client.selection.collectAsStateWithLifecycle() val kind by client.selectionKind.collectAsStateWithLifecycle() val selecting = selection.active && kind == SelectionKind.Tracks - var sheetFor by remember { mutableStateOf?>(null) } + val songMenu = app.hocket.ui.components.rememberSongMenu() + val removeFromPlaylist = stringResource(R.string.action_remove_from_playlist) var playlistSheet by remember { mutableStateOf(false) } val listState = rememberLazyListState() val reorderable = rememberReorderableLazyListState(listState) { from, to -> @@ -429,7 +429,12 @@ fun PlaylistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = val t = e.track val editable = !p.isSmart && p.isMine ReorderableItem(reorderable, key = e.key, enabled = editable) { dragging -> - TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playContext(context, startIndex = i)) }, onMore = { sheetFor = i to t }, + TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playContext(context, startIndex = i)) }, onMore = { + // The playlist's own extra: remove this entry (by its position). + val extras = if (editable) listOf(app.hocket.ui.components.SongMenuExtra(removeFromPlaylist, Icons.Filled.PlaylistRemove, + onClick = { client.dispatch(Commands.playlistRemove(p.id, listOf(i))) }, destructive = true, testTag = "songMenu.removeFromPlaylist")) else emptyList() + songMenu.open(t.toSummary(), extras = extras) + }, selected = selecting && selection.contains(t.id), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.Tracks, t.id) }, nowPlaying = nowPlaying?.track?.id == t.id, trailing = if (editable) ({ @@ -451,12 +456,6 @@ fun PlaylistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = } } if (p != null) { - sheetFor?.let { (i, t) -> - ActionSheet(Commands.tracks(listOf(t.id)), t.title, t.artist, onDismiss = { sheetFor = null }, onGoToAlbum = t.albumId?.let { aid -> { nav.navigate(Route.Album(aid)) } }, - extraTop = if (!p.isSmart && p.isMine) ({ - androidx.compose.material3.TextButton(onClick = { client.dispatch(Commands.playlistRemove(p.id, listOf(i))); sheetFor = null }, modifier = Modifier.padding(horizontal = 12.dp)) { Text(stringResource(R.string.action_remove_from_playlist)) } - }) else null) - } if (playlistSheet) ActionSheet(Commands.playlists(listOf(p.id)), p.name, p.owner, onDismiss = { playlistSheet = false }) } } @@ -476,7 +475,7 @@ fun GenreDetailScreen(nav: NavHostController, name: String, embedded: Boolean = tracks = t ?: tracks ?: emptyList() } val context = Commands.genreContext(serverId, name) - var sheetFor by remember { mutableStateOf(null) } + val songMenu = app.hocket.ui.components.rememberSongMenu() val al = albums val tr = tracks // The genre's name is known up front: the bar shows it at once, the rest loads under it. @@ -494,8 +493,7 @@ fun GenreDetailScreen(nav: NavHostController, name: String, embedded: Boolean = } item(key = "h.songs") { SectionHeader(stringResource(R.string.genre_songs)) } itemsIndexed(tr, key = { _, t -> t.id }, contentType = { _, _ -> "track" }) { i, t -> - TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playContext(context, startIndex = i)) }, onMore = { sheetFor = t }) + TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playContext(context, startIndex = i)) }, onMore = { songMenu.open(t.toSummary()) }) } } - sheetFor?.let { t -> ActionSheet(Commands.tracks(listOf(t.id)), t.title, t.artist, onDismiss = { sheetFor = null }, onGoToAlbum = t.albumId?.let { aid -> { nav.navigate(Route.Album(aid)) } }) } } diff --git a/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt index 229cf98..8dfecb7 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt @@ -347,7 +347,7 @@ internal fun SongsTab(serverId: String, sort: SortOrder, descending: Boolean, na val kind by client.selectionKind.collectAsStateWithLifecycle() val nowPlaying by client.nowPlaying.collectAsStateWithLifecycle() val selecting = selection.active && kind == SelectionKind.Tracks - var sheetFor by remember { mutableStateOf(null) } + val songMenu = app.hocket.ui.components.rememberSongMenu() val listLabel = stringResource(if (offlineOnly) R.string.available_offline else R.string.library_songs) LaunchedEffect(key, state.generation) { client.trackPages.ensure(key, 0) } LaunchedEffect(state.total) { if (state.total >= 0 && kind == SelectionKind.Tracks) client.setSelectionTotal(state.total) } @@ -383,15 +383,11 @@ internal fun SongsTab(serverId: String, sort: SortOrder, descending: Boolean, na // Play the sorted list as an ad-hoc context starting here: the visible page's ids are known, the rest resolve in the core. val ctx = Commands.adHocContext(serverId, listLabel, state.pages.toSortedMap().values.flatten().map { it.id }, effectiveSort) client.dispatch(Commands.playContext(ctx, startIndex = state.pages.toSortedMap().values.flatten().indexOfFirst { it.id == track.id }.coerceAtLeast(0))) - }, onMore = { sheetFor = track }, selected = selecting && selection.contains(track.id), selectionActive = selecting, + }, onMore = { songMenu.open(track.toSummary()) }, selected = selecting && selection.contains(track.id), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.Tracks, track.id) }, nowPlaying = nowPlaying?.track?.id == track.id) } else TrackRowSkeleton() } } - sheetFor?.let { t -> - ActionSheet(Commands.tracks(listOf(t.id)), t.title, t.artist, onDismiss = { sheetFor = null }, - onGoToAlbum = t.albumId?.let { id -> { nav.navigate(Route.Album(id)) } }, onGoToArtist = t.artistId?.let { id -> { nav.navigate(Route.Artist(id)) } }) - } } @Composable diff --git a/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt index f02c057..899b2c7 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt @@ -53,7 +53,6 @@ import app.hocket.core.Queries import app.hocket.core.api.QueryResult import app.hocket.core.api.SearchResults import app.hocket.ui.LocalCoreClient -import app.hocket.ui.components.ActionSheet import app.hocket.ui.screens.home.AlbumStrip import app.hocket.ui.components.ArtistRow import app.hocket.ui.components.EmptyState @@ -82,7 +81,7 @@ fun SearchScreen(nav: NavHostController) { var requestCounter by remember { mutableIntStateOf(0) } var minHeightPx by remember { mutableIntStateOf(0) } val focus = remember { FocusRequester() } - var sheetFor by remember { mutableStateOf(null) } + val songMenu = app.hocket.ui.components.rememberSongMenu() LaunchedEffect(Unit) { snapshotFlow { query }.debounce(120).collect { q -> @@ -126,7 +125,7 @@ fun SearchScreen(nav: NavHostController) { if (results.tracks.isNotEmpty()) { item { SectionHeader(stringResource(R.string.search_songs)) } items(results.tracks, key = { "t" + it.id }) { t -> - TrackRow(t, onClick = { client.dispatch(Commands.playTracks(serverId, results.tracks.map { it.id }, results.tracks.indexOf(t), query)) }, onMore = { sheetFor = t }) + TrackRow(t, onClick = { client.dispatch(Commands.playTracks(serverId, results.tracks.map { it.id }, results.tracks.indexOf(t), query)) }, onMore = { songMenu.open(t) }) } } if (results.albums.isNotEmpty()) { @@ -151,14 +150,13 @@ fun SearchScreen(nav: NavHostController) { if (pendingRemote) Text(stringResource(R.string.search_server_pending), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp)) } remote?.let { r -> - items(r.tracks, key = { "rt" + it.id }) { t -> TrackRow(t, onClick = { client.dispatch(Commands.playTracks(serverId, r.tracks.map { it.id }, r.tracks.indexOf(t), query)) }, onMore = { sheetFor = t }) } + items(r.tracks, key = { "rt" + it.id }) { t -> TrackRow(t, onClick = { client.dispatch(Commands.playTracks(serverId, r.tracks.map { it.id }, r.tracks.indexOf(t), query)) }, onMore = { songMenu.open(t) }) } if (r.albums.isNotEmpty()) item(key = "ralbums") { AlbumStrip(r.albums, Modifier.animateItem().padding(vertical = 8.dp)) { a -> nav.navigate(Route.Album(a.id)) } } items(r.artists, key = { "rar" + it.id }) { ar -> ArtistRow(ar, onClick = { nav.navigate(Route.Artist(ar.id)) }) } } } } } - sheetFor?.let { t -> ActionSheet(Commands.tracks(listOf(t.id)), t.title, t.artist, onDismiss = { sheetFor = null }, onGoToAlbum = t.albumId?.let { id -> { nav.navigate(Route.Album(id)) } }, onGoToArtist = t.artistId?.let { id -> { nav.navigate(Route.Artist(id)) } }) } } diff --git a/android/app/src/test/java/app/hocket/ui/SongMenuTest.kt b/android/app/src/test/java/app/hocket/ui/SongMenuTest.kt new file mode 100644 index 0000000..ef58799 --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/SongMenuTest.kt @@ -0,0 +1,177 @@ +package app.hocket.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.ActionIds +import app.hocket.core.api.ActionDescriptor +import app.hocket.core.api.OfflineState +import app.hocket.core.api.TrackSummary +import app.hocket.ui.components.songMenuRows +import app.hocket.ui.nav.AppRoot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The one song menu (hosted at the app level): swiping it away from the full player dismisses the + * menu and only the menu, it opens at its final height, and it is the same menu from the queue. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35], application = android.app.Application::class, qualifiers = "w411dp-h891dp") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SongMenuTest { + @get:Rule + val compose = createAndroidComposeRule() + + private fun displayed(tag: String): Boolean = runCatching { compose.onNodeWithTag(tag).assertIsDisplayed() }.isSuccess + private fun present(tag: String): Boolean = compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() + private fun sheetTop(): Float = compose.onNodeWithTag("nowPlaying.sheet").fetchSemanticsNode().boundsInRoot.top + private fun contentTop(): Float = runCatching { compose.onNodeWithTag("debug.fakeCoreBanner").fetchSemanticsNode().boundsInRoot.bottom }.getOrDefault(0f) + + private fun openPlayer(core: TestCore) { + compose.setThemedContent(core) { AppRoot(core.client) } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + compose.onNodeWithTag("nowPlaying.sheet").performSemanticsAction(SemanticsActions.Expand) + compose.waitUntil(5_000) { displayed("player.playPause") } + } + + private fun openMenu() { + compose.onNodeWithTag("player.more").performSemanticsAction(SemanticsActions.OnClick) + compose.waitUntil(5_000) { present("songMenu.header") } + compose.waitForIdle() + } + + private fun swipeMenu(by: Float, millis: Long) { + compose.onNodeWithTag("songMenu.header").performTouchInput { swipe(start = center, end = center + Offset(0f, by), durationMillis = millis) } + } + + @Test + fun swipingTheMenuAwayFromThePlayerDismissesOnlyTheMenu() { + val core = TestCore() + openPlayer(core) + val openTop = sheetTop() + assertTrue(openTop <= contentTop() + 1f) + repeat(3) { round -> + openMenu() + // A fling on odd rounds, a slow drag past half the screen on even ones. + if (round % 2 == 0) swipeMenu(1200f, 1_500) else swipeMenu(600f, 80) + compose.waitUntil(5_000) { !present("songMenu.header") } + compose.waitForIdle() + // The player stayed where it was: fully open, not dragged along with the menu. + assertTrue("player moved on round $round: ${sheetTop()} vs $openTop", sheetTop() <= openTop + 1f) + assertTrue(displayed("player.playPause")) + } + } + + private fun top(tag: String): Float = compose.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot.top + + /** + * The player's menu and a queue row's menu are one menu: the same header and star row, the + * common actions, and the context's own rows (the queue's remove, the player's sleep timer) + * after them. Removing from the menu removes that entry and closes only the menu. + */ + @Test + fun theQueueAndThePlayerOpenTheSameMenuWithTheirExtrasLast() { + val core = TestCore() + openPlayer(core) + openMenu() + assertTrue(present("rating")) + assertTrue(top("player.sleep") > top("songMenu.action.${ActionIds.PLAY_NEXT}")) + assertTrue(!present("songMenu.action.${ActionIds.REMOVE_FROM_QUEUE}")) + compose.onNodeWithTag("songMenu.header").performTouchInput { swipe(start = center, end = center + Offset(0f, 1200f), durationMillis = 100) } + compose.waitUntil(5_000) { !present("songMenu.header") } + + compose.onNodeWithTag("player.mode.queue").performSemanticsAction(SemanticsActions.OnClick) + compose.waitUntil(5_000) { present("queue.list") } + val before = core.client.queue.value.let { it.history.size + it.playingNext.size + it.upcoming.size } + val more = SemanticsMatcher("offers More options") { n -> n.config.getOrNull(SemanticsActions.CustomActions)?.any { it.label == "More options" } == true } + val row = compose.onAllNodes(more and hasAnyAncestor(hasTestTag("queue.list"))).fetchSemanticsNodes() + .first { n -> n.config.getOrNull(SemanticsActions.CustomActions)?.any { it.label == "Remove from queue" } == true } + row.config[SemanticsActions.CustomActions].first { it.label == "More options" }.action() + compose.waitUntil(5_000) { present("songMenu.header") } + compose.waitForIdle() + assertTrue(present("rating")) + assertTrue(!present("player.sleep")) + val remove = "songMenu.action.${ActionIds.REMOVE_FROM_QUEUE}" + assertTrue(top(remove) > top("songMenu.action.${ActionIds.PLAY_NEXT}")) + compose.onNodeWithTag(remove).performSemanticsAction(SemanticsActions.OnClick) + compose.waitUntil(5_000) { !present("songMenu.header") } + compose.waitUntil(5_000) { core.client.queue.value.let { it.history.size + it.playingNext.size + it.upcoming.size } == before - 1 } + assertTrue(displayed("player.playPause")) + } + + /** From a list row the menu is the same sheet: header, stars, the common actions, go-to that navigates. */ + @Test + fun aListRowOpensTheSameMenu() { + val core = TestCore(startPlaying = false) + val album = core.fake.library.albums[2] + val track = core.fake.library.albumTracks(album.id)[0] + val opened = mutableListOf() + core.start() + compose.setThemedContent(core) { + androidx.compose.runtime.CompositionLocalProvider(LocalDetailNavigator provides DetailNavigator({ opened += "album:$it" }, { opened += "artist:$it" })) { + app.hocket.ui.screens.detail.AlbumDetailScreen(androidx.navigation.compose.rememberNavController(), album.id) + } + } + compose.waitUntil(5_000) { compose.onAllNodesWithContentDescription("More options for ${track.title}").fetchSemanticsNodes().isNotEmpty() } + compose.onAllNodesWithContentDescription("More options for ${track.title}")[0].performSemanticsAction(SemanticsActions.OnClick) + compose.waitUntil(5_000) { present("songMenu.header") } + compose.waitForIdle() + assertTrue(present("rating")) + assertTrue(present("songMenu.action.${ActionIds.PLAY_NEXT}")) + assertTrue(!present("songMenu.action.${ActionIds.REMOVE_FROM_QUEUE}")) + compose.onNodeWithTag("songMenu.action.${ActionIds.GO_TO_ARTIST}").performSemanticsAction(SemanticsActions.OnClick) + compose.waitUntil(5_000) { !present("songMenu.header") } + assertEquals(listOf("artist:${track.artistId}"), opened) + } + + @Test + fun theRowsKeepTheRegistryOrderAndSplitOffTheContextActions() { + fun d(id: String) = ActionDescriptor(id, id, "x", "c", true, null, true, false) + val track = TrackSummary("t", "s", "T", albumId = "al", artistId = null, durationMs = 1u, rating = 0u, loved = false, offline = OfflineState.None) + val ids = listOf(ActionIds.REMOVE_FROM_QUEUE, ActionIds.PLAY_NEXT, ActionIds.rate(5), ActionIds.rate(0), ActionIds.GO_TO_ALBUM, ActionIds.GO_TO_ARTIST, ActionIds.DOWNLOAD) + val rows = songMenuRows(ids.map(::d), track, canNavigate = true) + assertEquals(listOf(ActionIds.PLAY_NEXT, ActionIds.GO_TO_ALBUM, ActionIds.DOWNLOAD), rows.common.map { it.id }) + assertEquals(listOf(ActionIds.REMOVE_FROM_QUEUE), rows.context.map { it.id }) + assertEquals(listOf(ActionIds.PLAY_NEXT, ActionIds.DOWNLOAD), songMenuRows(ids.map(::d), track, canNavigate = false).common.map { it.id }) + } + + /** The sheet's height from the frame it first shows: it opens at its final size, never grows under the finger. */ + @Test + fun theMenuOpensAtItsFinalHeight() { + val core = TestCore() + openPlayer(core) + compose.mainClock.autoAdvance = false + try { + compose.onNodeWithTag("player.more").performSemanticsAction(SemanticsActions.OnClick) + val heights = mutableListOf() + repeat(60) { + compose.mainClock.advanceTimeByFrame() + compose.onAllNodesWithTag("songMenu").fetchSemanticsNodes().firstOrNull()?.let { heights += it.size.height } + } + assertTrue("never shown", heights.isNotEmpty()) + assertTrue("the sheet changed height while opening: ${heights.distinct()}", heights.distinct().size == 1) + } finally { + compose.mainClock.autoAdvance = true + } + } +} From bd0249e85cf9a53fac55af4c0c247271e64996b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:24:30 +0000 Subject: [PATCH 06/15] android: full player - artwork at the top, title under it, the rest spread out; album in every mode; Playing from switches queues, Playing on opens the device picker; no floating thumbnail on swipe-away In lyrics / queue / about the artwork is a thumbnail beside the title near the bottom of the page. The sheet flew it, opaque, from there to the mini player's thumbnail while the page around it faded and the sheet's clip shrank, so it floated over the screen behind. Only the big artwork flies now; the thumbnail stays on the page (sliding and fading with it) and the mini player's own thumbnail fades in with the bar. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY --- .../app/hocket/ui/player/NowPlayingPage.kt | 298 ++++++++++++------ .../app/hocket/ui/player/NowPlayingSheet.kt | 31 +- .../hocket/ui/player/QueueSwitcherSheet.kt | 34 ++ .../java/app/hocket/ui/queue/RecentQueues.kt | 17 +- .../src/main/res/values/strings_player.xml | 1 + .../app/hocket/ui/FullPlayerLayoutTest.kt | 115 +++++++ 6 files changed, 377 insertions(+), 119 deletions(-) create mode 100644 android/app/src/main/java/app/hocket/ui/player/QueueSwitcherSheet.kt create mode 100644 android/app/src/test/java/app/hocket/ui/FullPlayerLayoutTest.kt diff --git a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt index 335043b..97c1183 100644 --- a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt +++ b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt @@ -41,6 +41,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.automirrored.filled.QueueMusic +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Bedtime import androidx.compose.material.icons.filled.Cast import androidx.compose.material.icons.filled.Close @@ -66,6 +67,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode @@ -130,19 +132,19 @@ import androidx.compose.foundation.combinedClickable /** * The full player (the owner's mockup, made Material 3 Expressive). Top to bottom: * - * - "Playing from" and the queue's source (album, playlist, search...), with Connect and the collapse chevron; - * - the mode area: the big artwork in [PlayerMode.Artwork], or Lyrics / Queue / About in its place; - * - notices (resume offer, a problem, autoplay's reason, remote playback, the sleep timer); - * - the title row: a small thumbnail slot (non-artwork modes), title, "artist • album" links, and - * add to playlist and the More sheet (which holds the rating and the sleep timer); - * - the wavy seek bar with elapsed / total, the transport, and the Lyrics / Queue / About pills. + * - "Playing from" and the queue's name (a tap switches queues), with Connect and the collapse chevron; + * - the mode area: the big artwork (at its top) in [PlayerMode.Artwork], or Lyrics / Queue / About; + * - the title row, right under the artwork: a small thumbnail slot (non-artwork modes), title, + * "artist • album" links, and add to playlist and the More sheet (the rating and the sleep timer); + * - spread over what is left: notices (resume offer, a problem, autoplay's reason, remote playback, + * the sleep timer), the wavy seek bar with elapsed / total, the transport, and the Lyrics / Queue / + * About pills ([PlayerLayout]). * * There is ONE artwork ([PlayerArtwork]), drawn over the page and moved in a graphics layer between * the big slot and the thumbnail slot as [modeFraction] goes 0 (artwork) to 1 (another mode); both * slots are measured into [hero], which the sheet's flying artwork also lands on. * - * At least a screen tall: the area takes what the header and the controls leave (never less than - * [MIN_AREA]); on short screens or at large font sizes the whole page scrolls. [position] is read + * At least a screen tall; on short screens or at large font sizes the whole page scrolls. [position] is read * in the draw phase (the seek bar) and once a second (its labels), never here. */ @Composable @@ -165,6 +167,7 @@ internal fun FullPlayer( val track = entry?.track val density = LocalDensity.current var handoff by remember { mutableStateOf(false) } + var queues by remember { mutableStateOf(false) } var sleepSheet by remember { mutableStateOf(false) } var more by remember { mutableStateOf(false) } var addTo by remember { mutableStateOf(false) } @@ -177,21 +180,28 @@ internal fun FullPlayer( Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).testTag("player.page")) { PlayerLayout( minHeight = viewport, - header = { PlayerHeader(onCollapse, onConnect = { handoff = true }) }, + hero = hero, + header = { PlayerHeader(onCollapse, onConnect = { handoff = true }, onSwitchQueue = { queues = true }) }, area = { ModeArea(mode, hero, lyricsVisible) }, - controls = { - PlayerControls( - mode = mode, - onMode = onMode, - onOpenAlbum = onOpenAlbum, - onOpenArtist = onOpenArtist, - position = position, - hero = hero, - pageWidth = pageWidth, - onMore = { more = true }, - onAddTo = { addTo = true }, - ) - }, + title = { PlayerTitle(mode, onOpenAlbum, onOpenArtist, hero, onMore = { more = true }, onAddTo = { addTo = true }) }, + items = listOf( + { PlayerNoticeLines(onConnect = { handoff = true }) }, + { PlayerSeek(position) }, + { + val client = LocalCoreClient.current + val playing by client.isPlaying.collectAsStateWithLifecycle() + // Narrow screens (display size "largest" leaves ~320 dp): a smaller transport. + TransportRow( + playing = playing, + onPrevious = { client.dispatch(Command.Previous) }, + onToggle = { client.dispatch(Command.TogglePlay) }, + onNext = { client.dispatch(Command.Next) }, + height = if (pageWidth < 360.dp) 68.dp else 80.dp, + modifier = Modifier.padding(horizontal = PAGE_PADDING), + ) + }, + { Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 8.dp)) { ModeBar(mode, onMode, pageWidth = pageWidth) } }, + ), ) } PlayerArtwork( @@ -207,6 +217,7 @@ internal fun FullPlayer( ) } if (handoff) HandoffSheet(onDismiss = { handoff = false }) + if (queues) QueueSwitcherSheet(onDismiss = { queues = false }) if (sleepSheet) SleepTimerSheet(onDismiss = { sleepSheet = false }) if (more && track != null) ActionSheet(Commands.tracks(listOf(track.id)), track.title, track.artist, onDismiss = { more = false }, onGoToAlbum = track.albumId?.let { id -> { onOpenAlbum(id) } }, onGoToArtist = track.artistId?.let { id -> { onOpenArtist(id) } }, @@ -224,45 +235,99 @@ private val PAGE_PADDING = 24.dp private val MAX_ART = 520.dp private val MIN_AREA = 220.dp +/** The least gap above each of the [PlayerLayout] items (notices, seek bar, transport, mode pills). */ +private val ITEM_GAPS = listOf(0.dp, 4.dp, 8.dp, 12.dp) + /** - * Header, mode area and controls stacked: the header and the controls take what they need, the - * area what is left of [minHeight] (at least [MIN_AREA]). Laid out inside the page's vertical - * scroll, so it can be taller than the screen. + * The page's layout. In artwork mode: the header, the artwork at the top of the mode area (as large + * as the width allows, up to [MAX_ART], shrinking on short screens but never below what [MIN_AREA] + * leaves), the title right below it, and the [items] spread evenly over what is left of + * [minHeight] (the same share above each and below the last, on top of [ITEM_GAPS]). In the other + * modes the area takes all that room (at least [MIN_AREA]) and the title and items stack at the + * bottom with just [ITEM_GAPS]. Between the two it follows [HeroGeometry.modeFraction], read here in + * the layout phase (the mode's animation relays out, never recomposes). The area's content is + * always laid out at its full (other-mode) height and clipped to the visible part, so lyrics and the + * queue do not relayout as it opens. Inside the page's vertical scroll, so it can be taller than + * the screen. The artwork's side goes to [HeroGeometry.artSide]. */ @Composable -private fun PlayerLayout(minHeight: Dp, header: @Composable () -> Unit, area: @Composable () -> Unit, controls: @Composable () -> Unit) { - Layout(contents = listOf(header, area, controls)) { (h, a, c), constraints -> - val loose = Constraints(maxWidth = constraints.maxWidth) - val hp = h.map { it.measure(loose) } - val cp = c.map { it.measure(loose) } +private fun PlayerLayout( + minHeight: Dp, + hero: HeroGeometry, + header: @Composable () -> Unit, + area: @Composable () -> Unit, + title: @Composable () -> Unit, + items: List<@Composable () -> Unit>, +) { + Layout(contents = listOf(header, area, title) + items) { slots, constraints -> + val w = constraints.maxWidth + val loose = Constraints(maxWidth = w) + val hp = slots[0].map { it.measure(loose) } + val tp = slots[2].map { it.measure(loose) } + val ip = slots.drop(3).map { s -> s.map { it.measure(loose) } } val hh = hp.maxOfOrNull { it.height } ?: 0 - val ch = cp.maxOfOrNull { it.height } ?: 0 - val areaH = maxOf(MIN_AREA.roundToPx(), minHeight.roundToPx() - hh - ch) - val ap = a.map { it.measure(Constraints.fixed(constraints.maxWidth, areaH)) } - layout(constraints.maxWidth, hh + areaH + ch) { + val th = tp.maxOfOrNull { it.height } ?: 0 + val ih = ip.map { p -> p.maxOfOrNull { it.height } ?: 0 } + // An empty item (no notices) takes no gap either. + val gaps = ih.mapIndexed { i, h -> if (h > 0) ITEM_GAPS.getOrElse(i) { 0.dp }.roundToPx() else 0 } + val rest = ih.sum() + gaps.sum() + val view = minHeight.roundToPx() + val pad = 8.dp.roundToPx() + val minArea = MIN_AREA.roundToPx() + // Artwork mode: the largest artwork that leaves room for the rest. + val maxSide = minOf(w - 2 * PAGE_PADDING.roundToPx(), MAX_ART.roundToPx()) + val side = (view - hh - th - rest - 2 * pad).coerceIn(minOf(minArea - 2 * pad, maxSide), maxSide).coerceAtLeast(0) + hero.artSide = side.toFloat() + val artArea = side + 2 * pad + val spare = (view - hh - artArea - th - rest).coerceAtLeast(0) + val shares = ih.count { it > 0 } + 1 + // Other modes: the area takes it all. + val fullArea = maxOf(minArea, view - hh - th - rest) + val f = hero.modeFraction().coerceIn(0f, 1f) + val areaH = lerpF(artArea.toFloat(), fullArea.toFloat(), f).roundToInt() + val share = spare * (1f - f) / shares + val ap = slots[1].map { it.measure(Constraints(minWidth = w, maxWidth = w, minHeight = areaH, maxHeight = maxOf(areaH, fullArea))) } + var y = hh + areaH + th + val tops = ih.mapIndexed { i, h -> if (h > 0) { y += gaps[i] + share.roundToInt(); val top = y; y += h; top } else y } + layout(w, maxOf(y + share.roundToInt(), view)) { hp.forEach { it.place(0, 0) } ap.forEach { it.place(0, hh) } - cp.forEach { it.place(0, hh + areaH) } + tp.forEach { it.place(0, hh + areaH) } + ip.forEachIndexed { i, p -> p.forEach { it.place(0, tops[i]) } } } } } -/** "Playing from" and the queue's source, and the collapse chevron. */ +/** + * "Playing from" and the queue's name (its context: the album, playlist, search...; "Autoplay" once + * autoplay has taken over a queue without one), Connect and the collapse chevron. Tapping the name + * opens the queue switcher (the recent and pinned queues). + */ @Composable -private fun PlayerHeader(onCollapse: () -> Unit, onConnect: () -> Unit) { +private fun PlayerHeader(onCollapse: () -> Unit, onConnect: () -> Unit, onSwitchQueue: () -> Unit) { val client = LocalCoreClient.current val owns by client.ownsTransport.collectAsStateWithLifecycle() val queue by client.queue.collectAsStateWithLifecycle() val entry by client.nowPlaying.collectAsStateWithLifecycle() - val source = when { - entry?.item?.source is QueueSource.Autoplay -> stringResource(R.string.player_playing_from_autoplay) - else -> queue.contextLabel?.takeIf { it.isNotBlank() } - } ?: stringResource(R.string.player_sheet_title) + val source = queue.contextLabel?.takeIf { it.isNotBlank() } + ?: (if (entry?.item?.source is QueueSource.Autoplay) stringResource(R.string.player_playing_from_autoplay) else null) + ?: stringResource(R.string.player_sheet_title) val sourceDesc = stringResource(R.string.player_playing_from_a11y, source) - Row(Modifier.fillMaxWidth().statusBarsPadding().padding(start = PAGE_PADDING, end = 12.dp, top = 12.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f).semantics(mergeDescendants = true) { contentDescription = sourceDesc }.testTag("player.source")) { - Text(stringResource(R.string.player_playing_from), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) - Text(source, style = MaterialTheme.typography.titleLargeEmphasized, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + val switchLabel = stringResource(R.string.player_switch_queue) + Row(Modifier.fillMaxWidth().statusBarsPadding().padding(start = PAGE_PADDING - 8.dp, end = 12.dp, top = 12.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Row( + Modifier.weight(1f).clip(RoundedCornerShape(12.dp)) + .clickable(onClickLabel = switchLabel, role = Role.Button, onClick = onSwitchQueue) + .semantics(mergeDescendants = true) { contentDescription = sourceDesc } + .padding(horizontal = 8.dp, vertical = 2.dp) + .testTag("player.source"), + verticalAlignment = Alignment.Bottom, + ) { + Column(Modifier.weight(1f, fill = false)) { + Text(stringResource(R.string.player_playing_from), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + Text(source, style = MaterialTheme.typography.titleLargeEmphasized, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Icon(Icons.Filled.ArrowDropDown, null, Modifier.padding(bottom = 2.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) } // Connect, highlighted while another device plays. Tap opens the picker; long-press pulls // playback straight to this device. @@ -297,12 +362,23 @@ private fun PlayerHeader(onCollapse: () -> Unit, onConnect: () -> Unit) { /** * The mode area: empty in artwork mode (the one artwork is drawn over it), else Lyrics, Queue or * About, crossfading with a slight grow as the artwork shrinks away. Its bounds go to [hero] (the - * big artwork is centred in them). Lists fade out at the area's top and bottom edges. + * big artwork sits at their top). Lists fade out at the area's top and bottom edges. + * + * [PlayerLayout] gives it its visible height as the least height and its full (other-mode) height + * as the most: the content is laid out at the full height and clipped to the visible part. */ @Composable private fun ModeArea(mode: PlayerMode, hero: HeroGeometry, lyricsVisible: Boolean) { val reduced = LocalReducedMotion.current - Box(Modifier.fillMaxSize().onGloballyPositioned { hero.area = hero.measure(it) }) { + Box( + Modifier + .onGloballyPositioned { hero.area = hero.measure(it) } + .clipToBounds() + .layout { measurable, constraints -> + val placeable = measurable.measure(Constraints.fixed(constraints.maxWidth, constraints.maxHeight)) + layout(constraints.maxWidth, constraints.minHeight) { placeable.place(0, 0) } + }, + ) { AnimatedContent( targetState = mode, transitionSpec = { @@ -334,70 +410,88 @@ private fun Modifier.fadingEdges(): Modifier = this drawRect(Brush.verticalGradient(0f to Color.Black, 1f to Color.Transparent, startY = size.height - bottom, endY = size.height), blendMode = BlendMode.DstIn, topLeft = Offset(0f, size.height - bottom), size = size.copy(height = bottom)) } +/** The title row ([TitleRow]), or what to do when nothing is queued. */ @Composable -private fun PlayerControls( - mode: PlayerMode, - onMode: (PlayerMode) -> Unit, - onOpenAlbum: (String) -> Unit, - onOpenArtist: (String) -> Unit, - position: () -> Long, - hero: HeroGeometry, - pageWidth: Dp, - onMore: () -> Unit, - onAddTo: () -> Unit, -) { +private fun PlayerTitle(mode: PlayerMode, onOpenAlbum: (String) -> Unit, onOpenArtist: (String) -> Unit, hero: HeroGeometry, onMore: () -> Unit, onAddTo: () -> Unit) { + val client = LocalCoreClient.current + val entry by client.nowPlaying.collectAsStateWithLifecycle() + val track = entry?.track + Column(Modifier.fillMaxWidth().padding(horizontal = PAGE_PADDING)) { + if (track == null) { + Column(Modifier.fillMaxWidth().padding(vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(stringResource(R.string.empty_queue_title), style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.empty_queue_body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + } + } else { + TitleRow(track, compact = mode != PlayerMode.Artwork, onOpenAlbum, onOpenArtist, hero, onMore, onAddTo) + } + } +} + +/** + * Notices: skipped-unavailable, resume offer, autoplay "why", remote playback (a tap opens the + * device picker), sleep timer. Nothing at all when there are none (the layout then gives it no gap). + */ +@Composable +private fun PlayerNoticeLines(onConnect: () -> Unit) { val client = LocalCoreClient.current val entry by client.nowPlaying.collectAsStateWithLifecycle() - val playing by client.isPlaying.collectAsStateWithLifecycle() - val transport by client.transport.collectAsStateWithLifecycle() val playerNotice by client.playerNotice.collectAsStateWithLifecycle() val notice = playerNoticeText(playerNotice) val resume by client.resumeOffer.collectAsStateWithLifecycle() val sleep by client.sleepTimer.collectAsStateWithLifecycle() val devices by client.devices.collectAsStateWithLifecycle() val owns by client.ownsTransport.collectAsStateWithLifecycle() - val reducedMotion = LocalReducedMotion.current - val track = entry?.track - Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 8.dp)) { - Column(Modifier.fillMaxWidth().padding(horizontal = PAGE_PADDING)) { - // Notices: skipped-unavailable, resume offer, autoplay "why", remote playback, sleep timer. - notice?.let { NoticeLine(it, MaterialTheme.colorScheme.error) } - resume?.let { offer -> - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.player_resume_offer, offer.deviceName, offer.track.title), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) - TextButton(onClick = { client.dispatch(Command.ResumeHere) }, modifier = Modifier.testTag("player.resumeHere")) { Text(stringResource(R.string.player_resume_here)) } - IconButton(onClick = { client.dispatch(Command.DismissResumeOffer) }) { Icon(Icons.Filled.Close, stringResource(R.string.action_dismiss)) } - } - } - (entry?.item?.source as? QueueSource.Autoplay)?.let { NoticeLine(stringResource(R.string.player_autoplay_reason, it.data.reason), MaterialTheme.colorScheme.tertiary) } - if (!owns) devices.firstOrNull { it.playing }?.let { NoticeLine(stringResource(R.string.player_playing_on, it.name), MaterialTheme.colorScheme.primary) } - sleep?.let { t -> - val label = t.endsAt?.let { stringResource(R.string.sleep_active, formatClock((it - System.currentTimeMillis()).toLong().coerceAtLeast(0))) } ?: stringResource(R.string.sleep_active_end_of_track) - NoticeLine(label, MaterialTheme.colorScheme.onSurfaceVariant) - } - if (track == null) { - Column(Modifier.fillMaxWidth().padding(vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Text(stringResource(R.string.empty_queue_title), style = MaterialTheme.typography.headlineSmall) - Text(stringResource(R.string.empty_queue_body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) - } - } else { - TitleRow(track, compact = mode != PlayerMode.Artwork, onOpenAlbum, onOpenArtist, hero, onMore, onAddTo) - Spacer(Modifier.height(4.dp)) - WavySeekBar(position = position, durationMs = track.durationMs.toLong(), playing = playing && !reducedMotion, onSeek = { client.dispatch(Commands.seekTo(it)) }) - if (transport.buffering) Text(stringResource(R.string.player_buffering), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Column(Modifier.fillMaxWidth().padding(horizontal = PAGE_PADDING)) { + notice?.let { NoticeLine(it, MaterialTheme.colorScheme.error) } + resume?.let { offer -> + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.player_resume_offer, offer.deviceName, offer.track.title), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + TextButton(onClick = { client.dispatch(Command.ResumeHere) }, modifier = Modifier.testTag("player.resumeHere")) { Text(stringResource(R.string.player_resume_here)) } + IconButton(onClick = { client.dispatch(Command.DismissResumeOffer) }) { Icon(Icons.Filled.Close, stringResource(R.string.action_dismiss)) } } - Spacer(Modifier.height(8.dp)) - // Narrow screens (display size "largest" leaves ~320 dp): a smaller transport. - TransportRow( - playing = playing, - onPrevious = { client.dispatch(Command.Previous) }, - onToggle = { client.dispatch(Command.TogglePlay) }, - onNext = { client.dispatch(Command.Next) }, - height = if (pageWidth < 360.dp) 68.dp else 80.dp, - ) - Spacer(Modifier.height(12.dp)) } - ModeBar(mode, onMode, pageWidth = pageWidth) + (entry?.item?.source as? QueueSource.Autoplay)?.let { NoticeLine(stringResource(R.string.player_autoplay_reason, it.data.reason), MaterialTheme.colorScheme.tertiary) } + if (!owns) devices.firstOrNull { it.playing }?.let { PlayingOnLine(it.name, onConnect) } + sleep?.let { t -> + val label = t.endsAt?.let { stringResource(R.string.sleep_active, formatClock((it - System.currentTimeMillis()).toLong().coerceAtLeast(0))) } ?: stringResource(R.string.sleep_active_end_of_track) + NoticeLine(label, MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +/** + * "Playing on " while another device plays: a tap (a 48 dp tall target) opens the device + * picker, as Connect in the header does. + */ +@Composable +private fun PlayingOnLine(device: String, onConnect: () -> Unit) { + val color = MaterialTheme.colorScheme.primary + Row( + Modifier.fillMaxWidth().heightIn(min = 48.dp).clip(RoundedCornerShape(12.dp)) + .clickable(onClickLabel = stringResource(R.string.player_connect), role = Role.Button, onClick = onConnect) + .testTag("player.playingOn"), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Filled.Cast, null, Modifier.size(18.dp), tint = color) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.player_playing_on, device), style = MaterialTheme.typography.labelLarge, color = color, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + Icon(Icons.Filled.ArrowDropDown, null, tint = color) + } +} + +/** The wavy seek bar with elapsed / total (and "buffering" under it while it is). */ +@Composable +private fun PlayerSeek(position: () -> Long) { + val client = LocalCoreClient.current + val entry by client.nowPlaying.collectAsStateWithLifecycle() + val playing by client.isPlaying.collectAsStateWithLifecycle() + val transport by client.transport.collectAsStateWithLifecycle() + val reducedMotion = LocalReducedMotion.current + val track = entry?.track ?: return + Column(Modifier.fillMaxWidth().padding(horizontal = PAGE_PADDING)) { + WavySeekBar(position = position, durationMs = track.durationMs.toLong(), playing = playing && !reducedMotion, onSeek = { client.dispatch(Commands.seekTo(it)) }) + if (transport.buffering) Text(stringResource(R.string.player_buffering), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } @@ -448,8 +542,8 @@ private fun TitleRow(track: app.hocket.core.api.TrackSummary, compact: Boolean, val subColor = MaterialTheme.colorScheme.onSurfaceVariant Text(track.artist ?: stringResource(R.string.unknown_artist), style = sub, color = subColor, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false).then(track.artistId?.let { id -> Modifier.textLink(goArtist) { onOpenArtist(id) } } ?: Modifier).testTag("player.artist")) - // Beside the thumbnail there is room for the artist only (the album is in About). - track.album?.takeIf { !compact }?.let { album -> + // Beside the thumbnail too: both shrink to share the row, each ellipsised. + track.album?.let { album -> Text(stringResource(R.string.dot_separator), style = sub, color = subColor, modifier = Modifier.clearAndSetSemantics { }) Text(album, style = sub, color = subColor, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false).then(track.albumId?.let { id -> Modifier.textLink(goAlbum) { onOpenAlbum(id) } } ?: Modifier).testTag("player.album")) diff --git a/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt b/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt index 556c60d..986f636 100644 --- a/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt +++ b/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt @@ -204,7 +204,7 @@ fun miniPlayerHeight(): Dp { /** * Where the artwork is, for the one artwork of the full player and the one that flies between the * players while the sheet moves. Measured relative to the sheet ([root]): the mini player's - * thumbnail ([thumb]), the full player's mode area ([area], the big artwork is centred in it) and + * thumbnail ([thumb]), the full player's mode area ([area], the big artwork sits at its top) and * the thumbnail slot beside the title ([small]). [playerArt] is where the full player's artwork is * for a mode fraction (0 = big, 1 = thumbnail); the sheet interpolates from [thumb] to it. */ @@ -218,6 +218,8 @@ internal class HeroGeometry { var artInset = 0f var artMax = Float.MAX_VALUE var artPad = 0f + /** The big artwork's side (px) as the full player's layout sized it for artwork mode; 0 until then. */ + var artSide by mutableStateOf(0f) /** The corners at both ends (px), set from the density. */ var bigCorner = 0f var smallCorner = 0f @@ -230,13 +232,17 @@ internal class HeroGeometry { return r.localBoundingBoxOf(coordinates, clipBounds = false) } - /** The big artwork's square: as large as its area allows (up to [artMax]), centred in it. */ + /** + * The big artwork's square, centred across the top of its area: [artSide] (the same in every + * mode, so the artwork does not resize while the area grows for lyrics or the queue), or before + * that is known as large as the area allows (up to [artMax]). + */ fun bigSlot(): Rect { val a = area if (a.width <= 0f || a.height <= 0f) return Rect.Zero - val side = minOf(a.width - 2 * artInset, a.height - 2 * artPad, artMax) + val side = if (artSide > 0f) minOf(artSide, a.width - 2 * artInset) else minOf(a.width - 2 * artInset, a.height - 2 * artPad, artMax) if (side <= 0f) return Rect.Zero - return Rect(Offset(a.center.x - side / 2, a.center.y - side / 2), Size(side, side)) + return Rect(Offset(a.center.x - side / 2, a.top + artPad), Size(side, side)) } /** Where the full player's artwork is at mode fraction [f], shrunk to [pausedScale] while big. */ @@ -334,7 +340,12 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( val scrimShown by remember(state) { derivedStateOf { state.progress > 0f } } val scrimTappable by remember(state) { derivedStateOf { state.progress > 0.5f } } val settledOpen by remember(state) { derivedStateOf { state.progress > 0.9f } } - val flying by remember(state) { derivedStateOf { state.progress.let { it > FLY_EPSILON && it < 1f - FLY_EPSILON } } } + // Only the big artwork flies to and from the mini player. In lyrics / queue / about the + // artwork is a thumbnail beside the title near the bottom of the page: flown opaque from + // there to the mini player's thumbnail it would float over the fading, shrinking sheet and + // over the screen behind it, so instead it stays on the page (sliding and fading with it) + // while the mini player's own thumbnail fades in with the bar. + val flying by remember(state) { derivedStateOf { state.mode == PlayerMode.Artwork && state.progress.let { it > FLY_EPSILON && it < 1f - FLY_EPSILON } } } // Scrim behind the sheet (a pointer affordance only: the collapse button and the sheet's // collapse/dismiss actions are the accessible way out). @@ -474,9 +485,9 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( MiniPlayerBar(onExpand = { scope.launch { state.expand() } }, position = readPosition, hero = hero, thumbHidden = { flying }) } } - // The one artwork that flies from the mini player's thumbnail to wherever the full - // player's artwork is (big, or small beside the title) while the sheet moves (both - // of those hide meanwhile). Decorative. + // The one artwork that flies from the mini player's thumbnail to the full player's + // big artwork while the sheet moves in artwork mode (both of those hide meanwhile; + // see `flying`). Decorative. FlyingArtwork(coverArt, hero, progress = { state.progress }, flying = { flying }, modeFraction = { modeAnim.value }, scale = { artScale.value }) } } @@ -505,8 +516,8 @@ private class SheetShape(private val inset: Float, private val corner: Float, pr /** * The artwork in flight: laid out at the big artwork's size and moved/scaled in a graphics layer - * from the mini player's thumbnail to wherever the full player's artwork is (the big slot, or the - * thumbnail beside the title in a non-artwork mode; both measured, so it lands exactly however the + * from the mini player's thumbnail to wherever the full player's artwork is (the big slot, or on + * its way to the thumbnail beside the title if the mode is changing; measured, so it lands exactly however the * page is scrolled or the status bar is sized). Its corners go from the thumbnail's to the target's, * and it gains the target's shadow. With nothing measured yet it just fades out from the thumbnail. */ diff --git a/android/app/src/main/java/app/hocket/ui/player/QueueSwitcherSheet.kt b/android/app/src/main/java/app/hocket/ui/player/QueueSwitcherSheet.kt new file mode 100644 index 0000000..9691570 --- /dev/null +++ b/android/app/src/main/java/app/hocket/ui/player/QueueSwitcherSheet.kt @@ -0,0 +1,34 @@ +package app.hocket.ui.player + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import app.hocket.R +import app.hocket.ui.queue.RecentQueuesList + +/** + * The queue switcher, from "Playing from" in the full player: the recent and pinned queues (the + * same list as the Recent queues screen); picking one restores it and closes the sheet. + */ +@Composable +fun QueueSwitcherSheet(onDismiss: () -> Unit) { + ModalBottomSheet(onDismissRequest = onDismiss, modifier = Modifier.testTag("queueSwitcher.sheet")) { + Column(Modifier.navigationBarsPadding()) { + Text(stringResource(R.string.home_saved_queues), style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(horizontal = 24.dp).semantics { heading() }) + RecentQueuesList(Modifier.fillMaxWidth().heightIn(min = 240.dp), contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp), onRestore = onDismiss) + } + } +} diff --git a/android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt b/android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt index 9641986..09d1032 100644 --- a/android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt +++ b/android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt @@ -62,9 +62,12 @@ import app.hocket.ui.components.EmptyState import app.hocket.ui.components.SectionHeader import app.hocket.ui.components.formatAgo -/** Saved queues: pinned first, then recent; restore / pin / delete / save as playlist. */ +/** + * Saved queues: pinned first, then recent; restore / pin / delete / save as playlist. [onRestore] + * runs after a queue is restored (the player's queue switcher closes itself). + */ @Composable -fun RecentQueuesList(modifier: Modifier = Modifier) { +fun RecentQueuesList(modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(bottom = app.hocket.ui.nav.BottomContentInset), onRestore: () -> Unit = {}) { val client = LocalCoreClient.current val saved by client.savedQueues.collectAsStateWithLifecycle() val connection by client.connection.collectAsStateWithLifecycle() @@ -76,9 +79,9 @@ fun RecentQueuesList(modifier: Modifier = Modifier) { } val pinned = saved.filter { it.pinned } val recent = saved.filter { !it.pinned }.sortedByDescending { it.lastInteractedAt } - LazyColumn(modifier, contentPadding = PaddingValues(bottom = app.hocket.ui.nav.BottomContentInset)) { - if (pinned.isNotEmpty()) { item { SectionHeader(stringResource(R.string.saved_pinned)) }; items(pinned, key = { it.id }) { SavedQueueRow(it, onDelete = { deleting = it }, onSaveAs = { naming = it }) } } - if (recent.isNotEmpty()) { item { SectionHeader(stringResource(R.string.saved_recent)) }; items(recent, key = { it.id }) { SavedQueueRow(it, onDelete = { deleting = it }, onSaveAs = { naming = it }) } } + LazyColumn(modifier, contentPadding = contentPadding) { + if (pinned.isNotEmpty()) { item { SectionHeader(stringResource(R.string.saved_pinned)) }; items(pinned, key = { it.id }) { SavedQueueRow(it, onDelete = { deleting = it }, onSaveAs = { naming = it }, onRestore = onRestore) } } + if (recent.isNotEmpty()) { item { SectionHeader(stringResource(R.string.saved_recent)) }; items(recent, key = { it.id }) { SavedQueueRow(it, onDelete = { deleting = it }, onSaveAs = { naming = it }, onRestore = onRestore) } } } deleting?.let { sq -> ConfirmDialog(stringResource(R.string.playlist_delete_confirm, sq.label), stringResource(R.string.action_delete), onConfirm = { client.dispatch(Commands.deleteSavedQueue(sq.id)) }, onDismiss = { deleting = null }) } naming?.let { sq -> @@ -91,9 +94,9 @@ fun RecentQueuesList(modifier: Modifier = Modifier) { } @Composable -private fun SavedQueueRow(sq: SavedQueue, onDelete: () -> Unit, onSaveAs: () -> Unit) { +private fun SavedQueueRow(sq: SavedQueue, onDelete: () -> Unit, onSaveAs: () -> Unit, onRestore: () -> Unit) { val client = LocalCoreClient.current - Row(Modifier.fillMaxWidth().clickable { client.dispatch(Commands.restoreSavedQueue(sq.id)) }.padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.fillMaxWidth().clickable { client.dispatch(Commands.restoreSavedQueue(sq.id)); onRestore() }.testTag("savedQueue.${sq.id}").padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { Artwork(sq.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(8.dp)) Spacer(Modifier.width(14.dp)) Column(Modifier.weight(1f)) { diff --git a/android/app/src/main/res/values/strings_player.xml b/android/app/src/main/res/values/strings_player.xml index 4a0bce0..3fd2261 100644 --- a/android/app/src/main/res/values/strings_player.xml +++ b/android/app/src/main/res/values/strings_player.xml @@ -4,6 +4,7 @@ Playing from Autoplay Playing from %1$s + Switch queue Lyrics Queue About diff --git a/android/app/src/test/java/app/hocket/ui/FullPlayerLayoutTest.kt b/android/app/src/test/java/app/hocket/ui/FullPlayerLayoutTest.kt new file mode 100644 index 0000000..b891b6b --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/FullPlayerLayoutTest.kt @@ -0,0 +1,115 @@ +package app.hocket.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.Commands +import app.hocket.ui.nav.AppRoot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The full player's page: the artwork at the top with the title right under it and the rest spread + * over the remaining height; the album beside the thumbnail in the other modes; "Playing from" + * opening the queue switcher and "Playing on" the device picker. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35], application = android.app.Application::class, qualifiers = "w411dp-h891dp") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class FullPlayerLayoutTest { + @get:Rule + val compose = createAndroidComposeRule() + + private val core = TestCore() + + private fun exists(tag: String) = compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() + private fun bounds(tag: String): Rect = compose.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot + private fun click(tag: String) = compose.onNodeWithTag(tag).performSemanticsAction(SemanticsActions.OnClick) + private val dp get() = compose.activity.resources.displayMetrics.density + + private fun startExpanded() { + compose.setThemedContent(core) { AppRoot(core.client) } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + compose.waitUntil(5_000) { exists("miniPlayer") } + compose.onNodeWithTag("nowPlaying.sheet").performSemanticsAction(SemanticsActions.Expand) + compose.waitUntil(5_000) { exists("player.playPause") } + compose.waitForIdle() + } + + @Test + fun theArtworkIsAtTheTopWithTheTitleUnderItAndTheRestSpreadOut() { + startExpanded() + val source = bounds("player.source") + val art = bounds("player.artwork") + val title = bounds("player.title") + val play = bounds("player.playPause") + val pills = bounds("player.mode.lyrics") + assertTrue("artwork right under the header: $source / $art", art.top - source.bottom in 0f..24f * dp) + assertTrue("title right under the artwork: $art / $title", title.top - art.bottom in 0f..40f * dp) + // The spare height goes between the controls (and under the last), not into one band: + // the transport and the pills are well apart but nothing hangs off the bottom. + val root = compose.onNodeWithTag("nowPlaying.sheet").fetchSemanticsNode().boundsInRoot + val gap = pills.top - play.bottom + assertTrue("the rest spread out (gap $gap)", gap > 16f * dp) + assertTrue("the pills clear of the screen's bottom (${pills.bottom} in $root)", pills.bottom < root.bottom) + assertTrue("and not all pushed to the bottom", root.bottom - pills.bottom > 12f * dp) + } + + @Test + fun theAlbumStaysBesideTheThumbnailInTheOtherModes() { + startExpanded() + val album = core.client.nowPlaying.value!!.track.album + // The lyrics animate every frame while shown: step the clock by hand instead of waiting for idle. + compose.mainClock.autoAdvance = false + try { + for (mode in listOf("lyrics", "queue", "about")) { + click("player.mode.$mode") + compose.mainClock.advanceTimeBy(1_000) + assertTrue("album shown in $mode", exists("player.album")) + if (album != null) assertEquals(album, compose.onNodeWithTag("player.album").fetchSemanticsNode().config[SemanticsProperties.Text].joinToString { it.text }) + click("player.mode.$mode") + compose.mainClock.advanceTimeBy(1_000) + } + } finally { + compose.mainClock.autoAdvance = true + } + } + + @Test + fun playingFromOpensTheQueueSwitcherAndPickingAQueueRestoresItAndClosesIt() { + startExpanded() + click("player.source") + compose.waitUntil(5_000) { exists("queueSwitcher.sheet") } + val saved = core.client.savedQueues.value.first { it.pinned } + compose.waitUntil(5_000) { exists("savedQueue.${saved.id}") } + click("savedQueue.${saved.id}") + compose.waitUntil(5_000) { !exists("queueSwitcher.sheet") } + compose.waitUntil(5_000) { core.client.queue.value.contextLabel == saved.label } + compose.waitForIdle() + assertEquals("Playing from ${saved.label}", compose.onNodeWithTag("player.source").fetchSemanticsNode().config[SemanticsProperties.ContentDescription].single()) + } + + @Test + fun playingOnAnotherDeviceOpensTheDevicePicker() { + startExpanded() + core.client.dispatch(Commands.handoffTo("laptop")) + // The client reads who owns the transport from snapshots. + core.client.requestSnapshot() + compose.waitUntil(5_000) { !core.client.ownsTransport.value } + compose.waitUntil(5_000) { exists("player.playingOn") } + click("player.playingOn") + compose.waitUntil(5_000) { exists("handoff.sheet") } + } +} From f1848c5ad5af680f582468b29954edb03dfdedcd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:46:27 +0000 Subject: [PATCH 07/15] android: remote Connect playback shows as the session's output device While another Hocket device plays, the media session (still controlling it through the core) reports remote playback: Media3 DeviceInfo PLAYBACK_TYPE_REMOTE with routing controller id "hocket-connect", fixed volume (Connect volume is per device and not forwarded). ConnectRouteProvider, a MediaRoute2ProviderService (API 30+), publishes the other Connect devices as routes (typed computer/smartphone on API 34+) and keeps a routing session with that id, named after the playing device. SystemUI matches the session's volume control id to it and shows the device on the media controls' output chip, as Spotify Connect does. Picking a device in the system output switcher hands off to it; picking this phone hands back. PlaybackService feeds ConnectRoutes from core events and holds the app's MediaRouter2 discovery preference, which keeps the provider bound. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY --- android/README.md | 10 +- android/playback/src/main/AndroidManifest.xml | 14 +- .../hocket/playback/ConnectRouteProvider.kt | 185 ++++++++++++++++++ .../java/app/hocket/playback/ConnectRoutes.kt | 89 +++++++++ .../app/hocket/playback/CoreSessionPlayer.kt | 20 +- .../app/hocket/playback/MediaSessionBridge.kt | 4 +- .../app/hocket/playback/PlaybackService.kt | 32 ++- .../app/hocket/playback/ConnectRoutesTest.kt | 87 ++++++++ .../src/media_session/PLATFORM_NOTES.md | 7 +- 9 files changed, 439 insertions(+), 9 deletions(-) create mode 100644 android/playback/src/main/java/app/hocket/playback/ConnectRouteProvider.kt create mode 100644 android/playback/src/main/java/app/hocket/playback/ConnectRoutes.kt create mode 100644 android/playback/src/test/java/app/hocket/playback/ConnectRoutesTest.kt diff --git a/android/README.md b/android/README.md index 2f4f01a..cafa427 100644 --- a/android/README.md +++ b/android/README.md @@ -88,7 +88,15 @@ the one core for the process through `CoreHost`: `Command.MediaSessionCommand`. Notification, lockscreen, Bluetooth and headset controls come from Media3; the service `addSession`s the session in `onCreate` (the UI is not a Media3 controller, so `onGetSession` alone would never register it and no notification or foreground promotion would - happen). Playback resumption from the system is refused (resuming is always explicit). + happen). +- Remote output: while another Connect device plays, `CoreSessionPlayer` reports + `DeviceInfo(PLAYBACK_TYPE_REMOTE, routingControllerId = "hocket-connect")` (fixed volume: Connect + volume is per device) and `ConnectRouteProvider` (a `MediaRoute2ProviderService`, API 30+) keeps a + routing session with that id, named after the playing device. SystemUI pairs the two and shows the + device on the media controls' output chip; the other devices are routes in the system output + switcher, and picking one (or this phone) is a `HandoffTo`. `PlaybackService` feeds + `ConnectRoutes` from `DevicesChanged`/`TransportChanged`/`MediaSession` and registers the app's + MediaRouter2 discovery preference for `app.hocket.feature.CONNECT`, which keeps the provider bound. Playback resumption from the system is refused (resuming is always explicit). - `NetworkMonitor` -> `SetNetworkState` (kind, metered, hashed SSID or transport id); `BatterySaverMonitor` -> `SetBatterySaver` while `battery.autoSaver` is on. - The app binds with `ACTION_BIND_CORE` (plus a per-process token, since the service is exported diff --git a/android/playback/src/main/AndroidManifest.xml b/android/playback/src/main/AndroidManifest.xml index ff3447c..f32ef52 100644 --- a/android/playback/src/main/AndroidManifest.xml +++ b/android/playback/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -22,6 +23,17 @@ + + + + + +