diff --git a/app/src/main/java/com/darkxvenom/airbeats/playback/DynamicIslandService.kt b/app/src/main/java/com/darkxvenom/airbeats/playback/DynamicIslandService.kt index 8d9e9336..d59a5e8c 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/playback/DynamicIslandService.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/playback/DynamicIslandService.kt @@ -625,12 +625,11 @@ private class DynamicIslandView( drawArtwork(canvas, art, corner = artSize / 2f, rotate = true) drawSpotifyWaveform(canvas, localWidth - 22f.dp, localHeight / 2f, compact = true) } else { - // Standard Full Pill Mode (Rotating circular artwork on left, live dot in center, waveform on right) + // Standard Full Pill Mode (Rotating circular artwork on left, waveform on right - center dot removed) val artSize = (localHeight - 8f.dp).coerceAtLeast(14f.dp) val art = RectF(5f.dp, 4f.dp, 5f.dp + artSize, 4f.dp + artSize) drawArtwork(canvas, art, corner = artSize / 2f, rotate = true) - drawLiveDot(canvas, localWidth / 2f, localHeight / 2f) - drawSpotifyWaveform(canvas, localWidth - 34f.dp, localHeight / 2f, compact = true) + drawSpotifyWaveform(canvas, localWidth - 22f.dp, localHeight / 2f, compact = true) } } diff --git a/app/src/main/java/com/darkxvenom/airbeats/playback/MediaLibrarySessionCallback.kt b/app/src/main/java/com/darkxvenom/airbeats/playback/MediaLibrarySessionCallback.kt index f201aefb..9e031f38 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/playback/MediaLibrarySessionCallback.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/playback/MediaLibrarySessionCallback.kt @@ -16,6 +16,7 @@ import androidx.annotation.DrawableRes import androidx.core.net.toUri import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player import androidx.media3.exoplayer.offline.Download import androidx.media3.session.LibraryResult import androidx.media3.session.MediaLibraryService @@ -138,6 +139,57 @@ constructor( return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } + override fun onPlayerCommandRequest( + session: MediaSession, + controller: MediaSession.ControllerInfo, + @Player.Command playerCommand: Int, + ): Int { + val syncSession = com.darkxvenom.airbeats.utils.ListenTogetherSync.session.value + val isHost = com.darkxvenom.airbeats.utils.ListenTogetherSync.isHost.value + if (syncSession != null && !isHost) { + val allowPlayback = syncSession.settings.allowGuestsToControlPlayback + when (playerCommand) { + Player.COMMAND_SEEK_TO_NEXT, + Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> { + if (!allowPlayback) { + com.darkxvenom.airbeats.utils.ListenTogetherSync.notifyDisallowedAction("Playback controls are disabled by the host") + return SessionResult.RESULT_ERROR_PERMISSION_DENIED + } + com.darkxvenom.airbeats.utils.ListenTogetherSync.sendGuestAction("skip_next") + com.darkxvenom.airbeats.playback.PlayerConnection.instance?.seekToNext() + return SessionResult.RESULT_SUCCESS + } + Player.COMMAND_SEEK_TO_PREVIOUS, + Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> { + if (!allowPlayback) { + com.darkxvenom.airbeats.utils.ListenTogetherSync.notifyDisallowedAction("Playback controls are disabled by the host") + return SessionResult.RESULT_ERROR_PERMISSION_DENIED + } + com.darkxvenom.airbeats.utils.ListenTogetherSync.sendGuestAction("skip_previous") + com.darkxvenom.airbeats.playback.PlayerConnection.instance?.seekToPrevious() + return SessionResult.RESULT_SUCCESS + } + Player.COMMAND_PLAY_PAUSE -> { + if (!allowPlayback) { + com.darkxvenom.airbeats.utils.ListenTogetherSync.notifyDisallowedAction("Playback controls are disabled by the host") + return SessionResult.RESULT_ERROR_PERMISSION_DENIED + } + com.darkxvenom.airbeats.utils.ListenTogetherSync.sendGuestAction("toggle_play_pause") + com.darkxvenom.airbeats.playback.PlayerConnection.instance?.togglePlayPause() + return SessionResult.RESULT_SUCCESS + } + Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM, + Player.COMMAND_SEEK_TO_MEDIA_ITEM -> { + if (!allowPlayback) { + com.darkxvenom.airbeats.utils.ListenTogetherSync.notifyDisallowedAction("Playback controls are disabled by the host") + return SessionResult.RESULT_ERROR_PERMISSION_DENIED + } + } + } + } + return super.onPlayerCommandRequest(session, controller, playerCommand) + } + override fun onGetLibraryRoot( session: MediaLibrarySession, browser: MediaSession.ControllerInfo, diff --git a/app/src/main/java/com/darkxvenom/airbeats/playback/PlayerConnection.kt b/app/src/main/java/com/darkxvenom/airbeats/playback/PlayerConnection.kt index 1e59f2e5..a40024ee 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/playback/PlayerConnection.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/playback/PlayerConnection.kt @@ -209,7 +209,6 @@ class PlayerConnection( Log.d(TAG, "PlayerConnection initialized successfully") } - private fun initializeStates() { try { _playbackState.value = player.playbackState @@ -239,12 +238,10 @@ class PlayerConnection( } } - fun refreshLikeStatus() { updateScope.launch(Dispatchers.IO) { updateLikeStatusForCurrentSong() } - } private fun handlePlayerEvents(player: Player, events: Player.Events) { @@ -332,7 +329,6 @@ class PlayerConnection( if (kotlin.math.abs(currentPos - lastPosition) > 500L || _duration.value != totalDuration ) { - _currentPosition.value = currentPos _duration.value = totalDuration _bufferedPosition.value = buffered @@ -359,7 +355,6 @@ class PlayerConnection( widgetUpdateHandler.postDelayed(pendingWidgetUpdate!!, WIDGET_UPDATE_DEBOUNCE) } - fun clearError() { _error.value = null } @@ -509,6 +504,36 @@ class PlayerConnection( fun removeQueueItem(index: Int, songId: String? = null) { try { + if (!songId.isNullOrBlank()) { + ListenTogetherSync.removeLocalSharedQueueItem(index, songId) + } + + val targetInTimeline = if (!songId.isNullOrBlank()) { + val cur = player.currentMediaItemIndex + var match = -1 + if (cur >= 0 && cur + 1 + index < player.mediaItemCount && player.getMediaItemAt(cur + 1 + index).mediaId == songId) { + cur + 1 + index + } else { + for (i in (cur + 1).coerceAtLeast(0) until player.mediaItemCount) { + if (player.getMediaItemAt(i).mediaId == songId) { + match = i + break + } + } + if (match < 0) { + for (i in 0 until player.mediaItemCount) { + if (player.getMediaItemAt(i).mediaId == songId) { + match = i + break + } + } + } + } + match + } else { + if (index in 0 until player.mediaItemCount) index else -1 + } + if (ListenTogetherSync.session.value != null && !ListenTogetherSync.isHost.value) { val allowed = ListenTogetherSync.session.value?.settings?.allowGuestsToAddTracks == true || ListenTogetherSync.session.value?.settings?.allowGuestsToControlPlayback == true @@ -521,13 +546,17 @@ class PlayerConnection( if (!songId.isNullOrBlank()) put("songId", songId) } ListenTogetherSync.sendGuestAction("remove_track", extraData = data) - if (index in 0 until player.mediaItemCount) { - player.removeMediaItem(index) + if (targetInTimeline >= 0) { + player.removeMediaItem(targetInTimeline) } return } - if (index in 0 until player.mediaItemCount) { - player.removeMediaItem(index) + + if (targetInTimeline >= 0) { + player.removeMediaItem(targetInTimeline) + if (ListenTogetherSync.session.value != null && ListenTogetherSync.isHost.value) { + ListenTogetherSync.publishNow() + } } } catch (e: Exception) { Log.e(TAG, "Error removing queue item at index: $index", e) @@ -537,6 +566,32 @@ class PlayerConnection( fun playQueueIndex(index: Int, songId: String? = null) { try { + val targetInTimeline = if (!songId.isNullOrBlank()) { + val cur = player.currentMediaItemIndex + var match = -1 + if (cur >= 0 && cur + 1 + index < player.mediaItemCount && player.getMediaItemAt(cur + 1 + index).mediaId == songId) { + cur + 1 + index + } else { + for (i in (cur + 1).coerceAtLeast(0) until player.mediaItemCount) { + if (player.getMediaItemAt(i).mediaId == songId) { + match = i + break + } + } + if (match < 0) { + for (i in 0 until player.mediaItemCount) { + if (player.getMediaItemAt(i).mediaId == songId) { + match = i + break + } + } + } + } + match + } else { + if (index in 0 until player.mediaItemCount) index else -1 + } + if (ListenTogetherSync.session.value != null && !ListenTogetherSync.isHost.value) { val allowed = ListenTogetherSync.session.value?.settings?.allowGuestsToControlPlayback == true if (!allowed) { @@ -548,15 +603,19 @@ class PlayerConnection( if (!songId.isNullOrBlank()) put("songId", songId) } ListenTogetherSync.sendGuestAction("play_index", extraData = data) - if (index in 0 until player.mediaItemCount) { - player.seekToDefaultPosition(index) + if (targetInTimeline >= 0) { + player.seekToDefaultPosition(targetInTimeline) player.play() } return } - if (index in 0 until player.mediaItemCount) { - player.seekToDefaultPosition(index) + + if (targetInTimeline >= 0) { + player.seekToDefaultPosition(targetInTimeline) player.play() + if (ListenTogetherSync.session.value != null && ListenTogetherSync.isHost.value) { + ListenTogetherSync.publishNow() + } } } catch (e: Exception) { Log.e(TAG, "Error playing queue item at index: $index", e) @@ -564,8 +623,14 @@ class PlayerConnection( } } - fun moveQueueItem(from: Int, to: Int) { + fun moveQueueItem(from: Int, to: Int, songId: String? = null) { try { + ListenTogetherSync.moveLocalSharedQueueItem(from, to) + + val cur = player.currentMediaItemIndex + val realFrom = if (cur >= 0 && cur + 1 + from < player.mediaItemCount) cur + 1 + from else from + val realTo = if (cur >= 0 && cur + 1 + to < player.mediaItemCount) cur + 1 + to else to + if (ListenTogetherSync.session.value != null && !ListenTogetherSync.isHost.value) { val allowed = ListenTogetherSync.session.value?.settings?.allowGuestsToAddTracks == true || ListenTogetherSync.session.value?.settings?.allowGuestsToControlPlayback == true @@ -573,15 +638,23 @@ class PlayerConnection( ListenTogetherSync.notifyDisallowedAction("Reordering queue is disabled by the host") return } - val data = org.json.JSONObject().put("from", from).put("to", to) + val data = org.json.JSONObject().apply { + put("from", from) + put("to", to) + if (!songId.isNullOrBlank()) put("songId", songId) + } ListenTogetherSync.sendGuestAction("move_track", extraData = data) - if (from in 0 until player.mediaItemCount && to in 0 until player.mediaItemCount) { - player.moveMediaItem(from, to) + if (realFrom in 0 until player.mediaItemCount && realTo in 0 until player.mediaItemCount && realFrom != realTo) { + player.moveMediaItem(realFrom, realTo) } return } - if (from in 0 until player.mediaItemCount && to in 0 until player.mediaItemCount) { - player.moveMediaItem(from, to) + + if (realFrom in 0 until player.mediaItemCount && realTo in 0 until player.mediaItemCount && realFrom != realTo) { + player.moveMediaItem(realFrom, realTo) + if (ListenTogetherSync.session.value != null && ListenTogetherSync.isHost.value) { + ListenTogetherSync.publishNow() + } } } catch (e: Exception) { Log.e(TAG, "Error moving queue item from $from to $to", e) @@ -609,7 +682,6 @@ class PlayerConnection( } } - fun isCurrentSongLiked(): Boolean { return _isLiked.value } @@ -765,29 +837,32 @@ class PlayerConnection( fun seekTo(positionMs: Long) { try { + val safeDuration = player.duration.coerceAtLeast(0L) + val targetPosition = if (safeDuration > 0L) positionMs.coerceIn(0L, safeDuration) else positionMs.coerceAtLeast(0L) + if (ListenTogetherSync.session.value != null && !ListenTogetherSync.isHost.value) { val allowed = ListenTogetherSync.session.value?.settings?.allowGuestsToControlPlayback == true if (!allowed) { ListenTogetherSync.notifyDisallowedAction("Playback controls are disabled by the host") return } - val safeDuration = player.duration.coerceAtLeast(0L) - val targetPosition = if (safeDuration > 0L) positionMs.coerceIn(0L, safeDuration) else positionMs.coerceAtLeast(0L) player.seekTo(targetPosition) _currentPosition.value = targetPosition ListenTogetherSync.sendGuestAction("seek", targetPosition) return } + Log.d(TAG, "Seeking to position: ${positionMs}ms") if (service.isCasting.value) { service.seekCastTo(positionMs) _currentPosition.value = positionMs return } - val safeDuration = player.duration.coerceAtLeast(0L) - val targetPosition = if (safeDuration > 0L) positionMs.coerceIn(0L, safeDuration) else positionMs.coerceAtLeast(0L) player.seekTo(targetPosition) _currentPosition.value = targetPosition + if (ListenTogetherSync.session.value != null && ListenTogetherSync.isHost.value) { + ListenTogetherSync.publishNow() + } } catch (e: Exception) { Log.e(TAG, "Error seeking to position", e) reportException(e) @@ -871,14 +946,40 @@ class PlayerConnection( updateCanSkipPreviousAndNext() scheduleWidgetUpdate() } + + if (ListenTogetherSync.session.value != null) { + if (ListenTogetherSync.isHost.value) { + ListenTogetherSync.publishNow() + } else { + ListenTogetherSync.onMediaItemTransitioned(mediaItem?.mediaId) + } + } + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int, + ) { + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + val session = ListenTogetherSync.session.value + if (session != null) { + if (ListenTogetherSync.isHost.value) { + ListenTogetherSync.publishNow() + } else { + val allowed = session.settings.allowGuestsToControlPlayback + if (allowed) { + ListenTogetherSync.sendGuestAction("seek", newPosition.positionMs) + } + } + } + } } private suspend fun updateLikeStatusForCurrentSong() { try { val currentSongId = withContext(Dispatchers.Main) { player.currentMediaItem?.mediaId } if (currentSongId != null) { - // Consultar la base de datos para obtener el estado actual del like - // Similar a como lo hace Player.kt con currentSong?.song?.liked val songWithInfo = database.song(currentSongId).first() _isLiked.value = songWithInfo?.song?.liked ?: false Timber.tag(TAG).d("Like status updated for song $currentSongId: ${_isLiked.value}") @@ -891,6 +992,7 @@ class PlayerConnection( _isLiked.value = false } } + override fun onTimelineChanged(timeline: Timeline, reason: Int) { Log.d(TAG, "Timeline changed (reason: $reason)") _queueWindows.value = player.getQueueWindows() diff --git a/app/src/main/java/com/darkxvenom/airbeats/recognition/MusicRecognitionOverlayManager.kt b/app/src/main/java/com/darkxvenom/airbeats/recognition/MusicRecognitionOverlayManager.kt index ffc2967b..7fc0632c 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/recognition/MusicRecognitionOverlayManager.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/recognition/MusicRecognitionOverlayManager.kt @@ -28,10 +28,22 @@ import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import android.widget.Toast import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.innertube.models.WatchEndpoint +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.playback.MusicService +import com.darkxvenom.airbeats.playback.PlayerConnection +import com.darkxvenom.airbeats.playback.queues.ListQueue +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.providers.ProviderSearchManager import com.darkxvenom.airbeats.shazamkit.Shazam import com.darkxvenom.airbeats.shazamkit.ShazamSignatureGenerator import com.darkxvenom.airbeats.shazamkit.models.RecognitionResult +import com.darkxvenom.airbeats.songs.IdentifiedSong import com.darkxvenom.airbeats.ui.sharedmusic.SharedMusicIdentificationActivity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -143,6 +155,7 @@ class MusicRecognitionOverlayManager private constructor(private val context: Co } statusTextView?.text = displayTitle statusTextView?.setTextColor(Color.WHITE) + statusTextView?.isSelected = true scheduleAutoDismiss(10000L) } @@ -273,23 +286,27 @@ class MusicRecognitionOverlayManager private constructor(private val context: Co if (overlayView != null && isOverlayShowing) return try { + val displayMetrics = context.resources.displayMetrics + // Fixed compact pill width to maintain original length between states + val overlayWidth = minOf(dpToPx(320f).toInt(), (displayMetrics.widthPixels - dpToPx(24f)).toInt()) + // Main compact horizontal pill container matching user's design val card = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL val bg = GradientDrawable().apply { setColor(Color.parseColor("#14131E")) - cornerRadius = dpToPx(30f) + cornerRadius = dpToPx(28f) setStroke(dpToPx(1f).toInt(), Color.parseColor("#2C2B3C")) } background = bg - setPadding(dpToPx(12f).toInt(), dpToPx(10f).toInt(), dpToPx(10f).toInt(), dpToPx(10f).toInt()) + setPadding(dpToPx(10f).toInt(), dpToPx(8f).toInt(), dpToPx(8f).toInt(), dpToPx(8f).toInt()) elevation = dpToPx(16f) } // Left: Official AirBeats App Icon (Circular) val iconView = ImageView(context).apply { - layoutParams = LinearLayout.LayoutParams(dpToPx(36f).toInt(), dpToPx(36f).toInt()) + layoutParams = LinearLayout.LayoutParams(dpToPx(32f).toInt(), dpToPx(32f).toInt()) setImageResource(R.mipmap.ic_launcher_round) clipToOutline = true outlineProvider = object : ViewOutlineProvider() { @@ -304,27 +321,30 @@ class MusicRecognitionOverlayManager private constructor(private val context: Co val appTitleTv = TextView(context).apply { text = "AirBeats" setTextColor(Color.WHITE) - textSize = 15.5f + textSize = 14f paint.isFakeBoldText = true - setPadding(dpToPx(8f).toInt(), 0, 0, 0) + setPadding(dpToPx(6f).toInt(), 0, 0, 0) } card.addView(appTitleTv) // Vertical subtle divider line val divider = View(context).apply { - layoutParams = LinearLayout.LayoutParams(dpToPx(1f).toInt(), dpToPx(20f).toInt()).apply { - marginStart = dpToPx(12f).toInt() - marginEnd = dpToPx(12f).toInt() + layoutParams = LinearLayout.LayoutParams(dpToPx(1f).toInt(), dpToPx(18f).toInt()).apply { + marginStart = dpToPx(8f).toInt() + marginEnd = dpToPx(8f).toInt() } setBackgroundColor(Color.parseColor("#2C2B3C")) } card.addView(divider) - // Middle Section Container (Waveform bars + Status text) + // Middle Section Container (Waveform bars + Status/Song text) + // Weight = 1f ensures the overlay width never changes when play button toggles val middleSection = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL - layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT) + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + isClickable = true + isFocusable = true setOnClickListener { if (currentResult != null) { launchReelIdentificationScreen(autoPlay = false) @@ -338,15 +358,15 @@ class MusicRecognitionOverlayManager private constructor(private val context: Co val waveContainer = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL - layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, dpToPx(22f).toInt()) + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, dpToPx(18f).toInt()) } waveformBars.clear() - val baseHeights = listOf(7f, 13f, 19f, 13f, 7f) + val baseHeights = listOf(6f, 11f, 16f, 11f, 6f) for (i in 0 until 5) { val bar = View(context).apply { val h = dpToPx(baseHeights[i]).toInt() - layoutParams = LinearLayout.LayoutParams(dpToPx(3.5f).toInt(), h).apply { - marginStart = if (i == 0) 0 else dpToPx(2.5f).toInt() + layoutParams = LinearLayout.LayoutParams(dpToPx(3f).toInt(), h).apply { + marginStart = if (i == 0) 0 else dpToPx(2f).toInt() } val barDrawable = GradientDrawable().apply { setColor(Color.parseColor("#8364FF")) @@ -360,27 +380,29 @@ class MusicRecognitionOverlayManager private constructor(private val context: Co waveformContainer = waveContainer middleSection.addView(waveContainer) - // Status label ("Listening for music...") + // Status label ("Listening for music..." or "🎵 Title • Artist") val statusTv = TextView(context).apply { text = "Listening for music..." setTextColor(Color.parseColor("#E0E0E0")) - textSize = 13.5f - maxLines = 1 - ellipsize = android.text.TextUtils.TruncateAt.END - setPadding(dpToPx(10f).toInt(), 0, dpToPx(6f).toInt(), 0) - val displayMetrics = context.resources.displayMetrics - val maxMiddleWidth = (displayMetrics.widthPixels - dpToPx(210f)).toInt() - maxWidth = maxMiddleWidth.coerceAtLeast(dpToPx(130f).toInt()) + textSize = 13f + isSingleLine = true + ellipsize = android.text.TextUtils.TruncateAt.MARQUEE + marqueeRepeatLimit = -1 + isSelected = true + isFocusable = true + setHorizontallyScrolling(true) + setPadding(dpToPx(6f).toInt(), 0, dpToPx(4f).toInt(), 0) + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) } statusTextView = statusTv middleSection.addView(statusTv) card.addView(middleSection) - // Right 1: Circular Play Button (▶) + // Right 1: Sleek Circular Vector Play Button val playBtnFrame = FrameLayout(context).apply { - layoutParams = LinearLayout.LayoutParams(dpToPx(34f).toInt(), dpToPx(34f).toInt()).apply { - marginStart = dpToPx(8f).toInt() + layoutParams = LinearLayout.LayoutParams(dpToPx(32f).toInt(), dpToPx(32f).toInt()).apply { + marginStart = dpToPx(6f).toInt() } val playBg = GradientDrawable().apply { shape = GradientDrawable.OVAL @@ -390,27 +412,28 @@ class MusicRecognitionOverlayManager private constructor(private val context: Co visibility = View.GONE setOnClickListener { if (currentResult != null) { - launchReelIdentificationScreen(autoPlay = true) + playSongInBackground(currentResult) } } } - val playTv = TextView(context).apply { - text = "▶" - setTextColor(Color.WHITE) - textSize = 14f - gravity = Gravity.CENTER - setPadding(dpToPx(2f).toInt(), 0, 0, 0) - layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + val playIv = ImageView(context).apply { + setImageResource(R.drawable.play) + setColorFilter(Color.WHITE) + val iconSize = dpToPx(16f).toInt() + layoutParams = FrameLayout.LayoutParams(iconSize, iconSize).apply { + gravity = Gravity.CENTER + marginStart = dpToPx(1.5f).toInt() + } } - playBtnFrame.addView(playTv) + playBtnFrame.addView(playIv) playButton = playBtnFrame card.addView(playBtnFrame) - // Right 2: Circular close button (✕) + // Right 2: Sleek Circular Vector Close Button (✕) val closeBtnFrame = FrameLayout(context).apply { - layoutParams = LinearLayout.LayoutParams(dpToPx(34f).toInt(), dpToPx(34f).toInt()).apply { - marginStart = dpToPx(6f).toInt() + layoutParams = LinearLayout.LayoutParams(dpToPx(32f).toInt(), dpToPx(32f).toInt()).apply { + marginStart = dpToPx(5f).toInt() } val closeBg = GradientDrawable().apply { shape = GradientDrawable.OVAL @@ -420,19 +443,19 @@ class MusicRecognitionOverlayManager private constructor(private val context: Co setOnClickListener { hide() } } - val closeTv = TextView(context).apply { - text = "✕" - setTextColor(Color.WHITE) - textSize = 14f - gravity = Gravity.CENTER - paint.isFakeBoldText = true - layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + val closeIv = ImageView(context).apply { + setImageResource(R.drawable.close) + setColorFilter(Color.parseColor("#D0D0E0")) + val iconSize = dpToPx(15f).toInt() + layoutParams = FrameLayout.LayoutParams(iconSize, iconSize).apply { + gravity = Gravity.CENTER + } } - closeBtnFrame.addView(closeTv) + closeBtnFrame.addView(closeIv) card.addView(closeBtnFrame) val layoutParams = WindowManager.LayoutParams( - WindowManager.LayoutParams.WRAP_CONTENT, + overlayWidth, WindowManager.LayoutParams.WRAP_CONTENT, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY @@ -460,6 +483,86 @@ class MusicRecognitionOverlayManager private constructor(private val context: Co } } + private fun playSongInBackground(res: RecognitionResult?) { + val result = res ?: return + hide() + + scope.launch(Dispatchers.IO) { + try { + val song = IdentifiedSong( + title = result.title, + artist = result.artist, + album = result.album, + albumArtUrl = result.coverArtHqUrl ?: result.coverArtUrl, + durationMs = null + ) + val providerSearch = ProviderSearchManager(context) + val candidates = runCatching { providerSearch.searchProviders(song) }.getOrDefault(emptyList()) + val topMatch = candidates.firstOrNull() + + val service = ensureMusicService() + + withContext(Dispatchers.Main) { + if (topMatch != null && topMatch.id.isNotBlank()) { + val queue = if (topMatch.provider == "JIOSAAVN") { + ListQueue( + title = topMatch.title, + items = listOf(topMatch.mediaMetadata.toMediaItem()) + ) + } else { + YouTubeQueue( + WatchEndpoint(videoId = topMatch.id), + topMatch.mediaMetadata + ) + } + val s = MusicService.instance ?: service + s?.playQueue(queue, playWhenReady = true) + s?.player?.play() + PlayerConnection.instance?.playQueue(queue) + Toast.makeText(context, "Playing: ${topMatch.title}", Toast.LENGTH_SHORT).show() + } else { + scope.launch(Dispatchers.IO) { + val query = "${result.title} ${result.artist.orEmpty()}".trim() + val yt = runCatching { YouTube.search(query, YouTube.SearchFilter.FILTER_SONG).getOrNull() }.getOrNull() + val topYt = yt?.items?.filterIsInstance()?.firstOrNull() + withContext(Dispatchers.Main) { + if (topYt != null) { + val q = YouTubeQueue(WatchEndpoint(topYt.id), topYt.toMediaMetadata()) + val activeS = MusicService.instance ?: service + activeS?.playQueue(q, playWhenReady = true) + activeS?.player?.play() + PlayerConnection.instance?.playQueue(q) + Toast.makeText(context, "Playing: ${topYt.title}", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, "Couldn't find playable track", Toast.LENGTH_SHORT).show() + } + } + } + } + } + } catch (e: Exception) { + Timber.e(e, "Error playing identified song in background") + } + } + } + + private fun ensureMusicService(): MusicService? { + val service = MusicService.instance ?: PlayerConnection.instance?.service + if (service == null) { + try { + val intent = Intent(context, MusicService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } catch (e: Exception) { + Timber.e(e, "Failed to start MusicService") + } + } + return service ?: MusicService.instance ?: PlayerConnection.instance?.service + } + private fun launchReelIdentificationScreen(autoPlay: Boolean = false) { val res = currentResult ?: return diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/ListenTogetherScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/ListenTogetherScreen.kt index aeb6676b..450c450c 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/ListenTogetherScreen.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/ListenTogetherScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -29,6 +30,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -84,6 +86,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext @@ -96,8 +99,10 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex import androidx.media3.common.Player import androidx.navigation.NavController import coil.compose.AsyncImage @@ -119,6 +124,7 @@ import com.darkxvenom.airbeats.utils.ListenTogetherSession import com.darkxvenom.airbeats.utils.ListenTogetherStore import com.darkxvenom.airbeats.utils.ListenTogetherSync import kotlinx.coroutines.launch +import kotlin.math.roundToInt private object MusicTogetherSpacing { val xxs = 4.dp @@ -486,7 +492,8 @@ fun ListenTogetherScreen( } ) - SharedQueueCard( + // Shared queue screen removed per user request: normal player queue is used + if (false) SharedQueueCard( queue = sharedQueue, canManageQueue = isHost || syncedIsHost || currentRoomSession.settings.allowGuestsToAddTracks || currentRoomSession.settings.allowGuestsToControlPlayback, canPlay = isHost || syncedIsHost || currentRoomSession.settings.allowGuestsToControlPlayback, @@ -495,6 +502,9 @@ fun ListenTogetherScreen( }, onRemoveItem = { index, songId -> playerConnection?.removeQueueItem(index, songId) + }, + onMoveItem = { from, to -> + playerConnection?.moveQueueItem(from, to) } ) } @@ -1454,12 +1464,24 @@ private fun SharedQueueCard( canPlay: Boolean, onPlayItem: (index: Int, songId: String) -> Unit, onRemoveItem: (index: Int, songId: String) -> Unit, + onMoveItem: (from: Int, to: Int) -> Unit, ) { + var isExpanded by rememberSaveable { mutableStateOf(true) } + var draggingIndex by remember { mutableStateOf(null) } + var dragOffsetY by remember { mutableStateOf(0f) } + SectionCard( iconResId = R.drawable.queue_music, titleResId = R.string.together_shared_queue, - subtitle = "${queue.size} upcoming", + subtitle = if (queue.isEmpty()) { + stringResource(R.string.together_queue_empty) + } else { + "${queue.size} upcoming • ${if (isExpanded) "Tap to collapse" else "Tap to expand"}" + }, accent = MaterialTheme.colorScheme.primary, + isCollapsible = true, + isExpanded = isExpanded, + onHeaderClick = { isExpanded = !isExpanded }, ) { if (queue.isEmpty()) { EmptyPanel( @@ -1472,7 +1494,18 @@ private fun SharedQueueCard( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - queue.take(20).forEachIndexed { idx, item -> + if (canManageQueue && queue.size > 1) { + Text( + text = "Hold & drag handle or use arrows to reorder upcoming songs", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.85f), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + ) + } + + queue.take(25).forEachIndexed { idx, item -> + val isItemDragging = draggingIndex == idx + ListItem( headlineContent = { Text( @@ -1493,21 +1526,41 @@ private fun SharedQueueCard( } } else null, leadingContent = { - AsyncImage( - model = item.thumbnailUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(42.dp) - .clip(RoundedCornerShape(8.dp)), - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(6.dp), + ) { + Text( + text = "#${idx + 1}", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp), + ) + } + AsyncImage( + model = item.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(42.dp) + .clip(RoundedCornerShape(8.dp)), + ) + } }, trailingContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { if (canPlay) { IconButton( - onClick = { onPlayItem(idx + 1, item.songId) }, - modifier = Modifier.size(36.dp), + onClick = { onPlayItem(idx, item.songId) }, + modifier = Modifier.size(34.dp), ) { Icon( painter = painterResource(R.drawable.play), @@ -1518,9 +1571,35 @@ private fun SharedQueueCard( } } if (canManageQueue) { + if (idx > 0) { + IconButton( + onClick = { onMoveItem(idx, idx - 1) }, + modifier = Modifier.size(30.dp), + ) { + Icon( + painter = painterResource(R.drawable.arrow_upward), + contentDescription = "Move Up", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (idx < queue.size - 1) { + IconButton( + onClick = { onMoveItem(idx, idx + 1) }, + modifier = Modifier.size(30.dp), + ) { + Icon( + painter = painterResource(R.drawable.arrow_downward), + contentDescription = "Move Down", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } IconButton( - onClick = { onRemoveItem(idx + 1, item.songId) }, - modifier = Modifier.size(36.dp), + onClick = { onRemoveItem(idx, item.songId) }, + modifier = Modifier.size(34.dp), ) { Icon( painter = painterResource(R.drawable.close), @@ -1529,23 +1608,83 @@ private fun SharedQueueCard( tint = MaterialTheme.colorScheme.error, ) } + Box( + modifier = Modifier + .padding(horizontal = 2.dp) + .size(24.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.drag_handle), + contentDescription = "Hold to drag", + modifier = Modifier.size(18.dp), + tint = if (isItemDragging) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } } } }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), + colors = ListItemDefaults.colors( + containerColor = if (isItemDragging) { + MaterialTheme.colorScheme.surfaceContainerHigh + } else { + Color.Transparent + } + ), modifier = Modifier .fillMaxWidth() + .offset { + if (isItemDragging) IntOffset(0, dragOffsetY.roundToInt()) else IntOffset.Zero + } + .zIndex(if (isItemDragging) 10f else 1f) .clip(RoundedCornerShape(12.dp)) .then( - if (canPlay) { - Modifier.clickable { onPlayItem(idx + 1, item.songId) } + if (isItemDragging) { + Modifier.border(1.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(12.dp)) } else Modifier - ), + ) + .then( + if (canPlay && !isItemDragging) { + Modifier.clickable { onPlayItem(idx, item.songId) } + } else Modifier + ) + .pointerInput(idx, canManageQueue, queue.size) { + if (!canManageQueue) return@pointerInput + detectDragGesturesAfterLongPress( + onDragStart = { + draggingIndex = idx + dragOffsetY = 0f + }, + onDrag = { change, dragAmount -> + change.consume() + dragOffsetY += dragAmount.y + val rowHeightPx = 140f + if (dragOffsetY > rowHeightPx && idx < queue.size - 1) { + onMoveItem(idx, idx + 1) + draggingIndex = idx + 1 + dragOffsetY -= rowHeightPx + } else if (dragOffsetY < -rowHeightPx && idx > 0) { + onMoveItem(idx, idx - 1) + draggingIndex = idx - 1 + dragOffsetY += rowHeightPx + } + }, + onDragEnd = { + draggingIndex = null + dragOffsetY = 0f + }, + onDragCancel = { + draggingIndex = null + dragOffsetY = 0f + } + ) + }, ) } - if (queue.size > 20) { + + if (queue.size > 25) { Text( - text = "+${queue.size - 20} more in queue (view in player queue)", + text = "+${queue.size - 25} more in queue", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/LanTogetherServer.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/LanTogetherServer.kt index d1968f10..bcf8589a 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/utils/LanTogetherServer.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/LanTogetherServer.kt @@ -531,7 +531,7 @@ class LanTogetherServer( controllerName = participants[controllerId]?.name ?: hostDisplayName, stateVersion = stateVersion, serverNow = System.currentTimeMillis(), - state = playbackState?.copy(settings = settings), + state = playbackState?.let { s -> s.copy(artists = s.artists.filterNot { it.startsWith(ListenTogetherPlaybackState.META_PREFIX) || it.startsWith(ListenTogetherPlaybackState.QUEUE_PREFIX) }, settings = settings) }, settings = settings, ) } @@ -2267,7 +2267,8 @@ class LanTogetherServer( var activeClass = isCurrent ? ' active' : ''; var lockedClass = canControl ? '' : ' locked'; var title = escapeHtml(item.title || 'Unknown Title'); - var artist = escapeHtml(item.artist || 'AirBeats'); + var rawArtist = (item.artist || 'AirBeats').replace(/__AB_[^;]*(;)?/g, '').trim(); + var artist = escapeHtml(rawArtist || 'AirBeats'); var songIdEsc = escapeHtml(item.id || ''); var thumbUrl = item.thumbnailUrl ? escapeHtml(item.thumbnailUrl) : ''; var thumbHtml = thumbUrl @@ -2349,7 +2350,8 @@ class LanTogetherServer( } if (titleEl) titleEl.innerText = state.title; - var artistStr = (state.artists && state.artists.length) ? state.artists.join(', ') : 'AirBeats'; + var cleanArtists = (state.artists || []).filter(function(a) { return a && typeof a === 'string' && !a.startsWith('__AB_META__:') && !a.startsWith('__AB_Q'); }); + var artistStr = cleanArtists.length ? cleanArtists.join(', ') : 'AirBeats'; if (artistEl) artistEl.innerText = artistStr; if (descEl) descEl.innerText = 'Streaming live over LAN • Host: ' + (data.hostName || 'AirBeats'); document.title = (state.isPlaying ? '\u25B6 ' : '\u23F8 ') + state.title + ' \u2022 AirBeats'; diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/ListenTogetherClient.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/ListenTogetherClient.kt index 5f4ba5dd..1f7aed9e 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/utils/ListenTogetherClient.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/ListenTogetherClient.kt @@ -14,6 +14,9 @@ import org.json.JSONObject import java.util.concurrent.TimeUnit object ListenTogetherClient { + const val META_PREFIX = "__AB_META__:" + const val QUEUE_PREFIX = "__AB_Q" + val BACKEND_URL: String get() = RemoteConfigManager.listenTogetherUrl @@ -32,7 +35,7 @@ object ListenTogetherClient { "$BACKEND_URL/sessions", JSONObject() .put("name", displayName) - .put("state", state.toJson()) + .put("state", state.toJson(forOnlineWorker = true)) .put("settings", state.settings.toJson()) ) { body -> ListenTogetherSession.fromJson(body) @@ -63,7 +66,7 @@ object ListenTogetherClient { "$BACKEND_URL/sessions/${code.trim().uppercase()}/state", JSONObject() .put("participantId", participantId) - .put("state", state.toJson()) + .put("state", state.toJson(forOnlineWorker = true)) .put("settings", state.settings.toJson()) ) { body -> ListenTogetherSession.fromJson(body) @@ -209,7 +212,7 @@ data class ListenTogetherSession( .put("controllerName", controllerName) .put("stateVersion", stateVersion) .put("serverNow", serverNow) - .put("state", state?.toJson()) + .put("state", state?.toJson(forOnlineWorker = false)) .put("settings", settings.toJson()) .put("queue", queueArr) } @@ -300,58 +303,33 @@ data class ListenTogetherPlaybackState( thumbnailUrl = thumbnailUrl, ) - fun toJson(): JSONObject { - val metaString = buildString { - append(META_PREFIX) - append("pb=").append(if (settings.allowGuestsToControlPlayback) "1" else "0") - append("&tr=").append(if (settings.allowGuestsToAddTracks) "1" else "0") - if (!actionCommand.isNullOrBlank()) { - append("&cmd=").append(java.net.URLEncoder.encode(actionCommand, "UTF-8")) - } - if (!actionPayload.isNullOrBlank()) { - append("&pl=").append(java.net.URLEncoder.encode(actionPayload, "UTF-8")) - } - if (!actionId.isNullOrBlank()) { - append("&actId=").append(java.net.URLEncoder.encode(actionId, "UTF-8")) - } - if (durationMs > 0L) { - append("&dur=").append(durationMs) - } - if (totalQueueSize > 0) { - append("&qSize=").append(totalQueueSize) - } - } - val cleanArtists = artists.filterNot { it.startsWith(META_PREFIX) || it.startsWith(QUEUE_PREFIX) }.take(1).toMutableList() + fun toJson(forOnlineWorker: Boolean = false): JSONObject { + val cleanArtists = artists.filterNot { it.startsWith(META_PREFIX) || it.startsWith(QUEUE_PREFIX) } val artistsList = mutableListOf() cleanArtists.forEach { artistsList.add(it) } - artistsList.add(metaString) - - // Tunnel shared queue through available slots in artists (worker allows up to 8 slots, 120 chars each) - if (queue.isNotEmpty()) { - val maxSlots = (8 - artistsList.size).coerceAtLeast(0) - var currentSlotIndex = 0 - var currentBuffer = StringBuilder() - - for (item in queue) { - if (currentSlotIndex >= maxSlots) break - val safeTitle = item.title.replace(":", " ").replace(";", " ").trim().take(22) - val entry = "${item.songId}:$safeTitle" - val prefix = "$QUEUE_PREFIX$currentSlotIndex:" - val testLength = prefix.length + (if (currentBuffer.isEmpty()) 0 else currentBuffer.length + 1) + entry.length - if (testLength > 118) { - if (currentBuffer.isNotEmpty()) { - artistsList.add("$prefix$currentBuffer") - currentSlotIndex++ - currentBuffer = StringBuilder() - } - if (currentSlotIndex >= maxSlots) break + + if (forOnlineWorker) { + val metaString = buildString { + append(META_PREFIX) + append("pb=").append(if (settings.allowGuestsToControlPlayback) "1" else "0") + append("&tr=").append(if (settings.allowGuestsToAddTracks) "1" else "0") + if (!actionCommand.isNullOrBlank()) { + append("&cmd=").append(java.net.URLEncoder.encode(actionCommand, "UTF-8")) + } + if (!actionPayload.isNullOrBlank()) { + append("&pl=").append(java.net.URLEncoder.encode(actionPayload, "UTF-8")) + } + if (!actionId.isNullOrBlank()) { + append("&actId=").append(java.net.URLEncoder.encode(actionId, "UTF-8")) + } + if (durationMs > 0L) { + append("&dur=").append(durationMs) + } + if (totalQueueSize > 0) { + append("&qSize=").append(totalQueueSize) } - if (currentBuffer.isNotEmpty()) currentBuffer.append(";") - currentBuffer.append(entry) - } - if (currentBuffer.isNotEmpty() && currentSlotIndex < maxSlots) { - artistsList.add("$QUEUE_PREFIX$currentSlotIndex:$currentBuffer") } + artistsList.add(metaString) } val queueArray = JSONArray() @@ -471,4 +449,3 @@ data class ListenTogetherPlaybackState( } } } - diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/ListenTogetherSync.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/ListenTogetherSync.kt index 0dc7c6b3..239f1deb 100644 --- a/app/src/main/java/com/darkxvenom/airbeats/utils/ListenTogetherSync.kt +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/ListenTogetherSync.kt @@ -160,6 +160,7 @@ object ListenTogetherSync { appContext = context.applicationContext lastAppliedVersion = session.stateVersion loadingSongId = null + lastSongLoadAttempt = 0L ListenTogetherStore.save( context = context.applicationContext, session = session, @@ -218,6 +219,8 @@ object ListenTogetherSync { _isHost.value = false _connectionMode.value = ListenTogetherConnectionMode.ONLINE lastAppliedVersion = 0L + loadingSongId = null + lastSongLoadAttempt = 0L ListenTogetherStore.save(context, it, _displayName.value, false, isLan = false) setDisplayMessage("Joined session") restartSync() @@ -271,7 +274,8 @@ object ListenTogetherSync { if (!player.currentTimeline.isEmpty) { val count = player.mediaItemCount val currentIndex = player.currentMediaItemIndex - for (i in 0 until count) { + val start = (currentIndex + 1).coerceAtLeast(0) + for (i in start until count) { val item = player.getMediaItemAt(i) val meta = item.mediaMetadata val t = meta.title?.toString() ?: "Unknown Song" @@ -279,7 +283,7 @@ object ListenTogetherSync { val thumb = meta.artworkUri?.toString() currentQueue.add(ListenTogetherQueueItem(songId = item.mediaId, title = t, artist = a, thumbnailUrl = thumb)) list.add(JSONObject().apply { - put("index", i) + put("index", i - start) put("id", item.mediaId) put("title", t) put("artist", a) @@ -324,17 +328,30 @@ object ListenTogetherSync { "play_index", "jump_to" -> { val targetIndex = extraData?.optInt("index", -1) ?: -1 val targetSongId = extraData?.optString("songId") - if (targetIndex >= 0 && targetIndex < player.mediaItemCount) { - player.seekToDefaultPosition(targetIndex) - player.play() - } else if (!targetSongId.isNullOrBlank()) { - for (i in 0 until player.mediaItemCount) { + var match = -1 + val cur = player.currentMediaItemIndex + if (!targetSongId.isNullOrBlank()) { + for (i in (cur + 1).coerceAtLeast(0) until player.mediaItemCount) { if (player.getMediaItemAt(i).mediaId == targetSongId) { - player.seekToDefaultPosition(i) - player.play() + match = i break } } + if (match < 0) { + for (i in 0 until player.mediaItemCount) { + if (player.getMediaItemAt(i).mediaId == targetSongId) { + match = i + break + } + } + } + } else if (targetIndex >= 0) { + val upcomingIdx = cur + 1 + targetIndex + match = if (upcomingIdx in 0 until player.mediaItemCount) upcomingIdx else targetIndex + } + if (match in 0 until player.mediaItemCount) { + player.seekToDefaultPosition(match) + player.play() } } "play_song" -> { @@ -373,20 +390,45 @@ object ListenTogetherSync { "remove_track", "remove_song" -> { val targetIndex = extraData?.optInt("index", -1) ?: -1 val targetSongId = extraData?.optString("songId") - if (targetIndex >= 0 && targetIndex < player.mediaItemCount) { - player.removeMediaItem(targetIndex) - } else if (!targetSongId.isNullOrBlank()) { - connection.removeSongFromQueue(targetSongId) + var match = -1 + val cur = player.currentMediaItemIndex + if (!targetSongId.isNullOrBlank()) { + for (i in (cur + 1).coerceAtLeast(0) until player.mediaItemCount) { + if (player.getMediaItemAt(i).mediaId == targetSongId) { + match = i + break + } + } + if (match < 0) { + for (i in 0 until player.mediaItemCount) { + if (player.getMediaItemAt(i).mediaId == targetSongId) { + match = i + break + } + } + } + } else if (targetIndex >= 0) { + val upcomingIdx = cur + 1 + targetIndex + match = if (upcomingIdx in 0 until player.mediaItemCount) upcomingIdx else targetIndex + } + if (match in 0 until player.mediaItemCount) { + player.removeMediaItem(match) } } "move_track" -> { val from = extraData?.optInt("from", -1) ?: -1 val to = extraData?.optInt("to", -1) ?: -1 - if (from >= 0 && to >= 0 && from < player.mediaItemCount && to < player.mediaItemCount) { + val cur = player.currentMediaItemIndex + val fromTimeline = cur + 1 + from + val toTimeline = cur + 1 + to + if (fromTimeline in 0 until player.mediaItemCount && toTimeline in 0 until player.mediaItemCount) { + player.moveMediaItem(fromTimeline, toTimeline) + } else if (from in 0 until player.mediaItemCount && to in 0 until player.mediaItemCount) { player.moveMediaItem(from, to) } } } + server.stateVersion++ } } server.start(30000, true) @@ -428,6 +470,8 @@ object ListenTogetherSync { _session.value = newSession _isHost.value = false lastAppliedVersion = 0L + loadingSongId = null + lastSongLoadAttempt = 0L ListenTogetherStore.save( context = context, session = newSession, @@ -468,6 +512,7 @@ object ListenTogetherSync { lanServer = null lanHostAddress = null loadingSongId = null + lastSongLoadAttempt = 0L playerConnection?.player?.let { player -> if (player.playbackParameters.speed != 1.0f) { player.playbackParameters = androidx.media3.common.PlaybackParameters(1.0f) @@ -595,13 +640,17 @@ object ListenTogetherSync { if (now > suppressLocalSyncUntil) { if (isWrongSong || isNewVersion) { applyRemoteState(connection, remoteSession) - lastAppliedVersion = remoteSession.stateVersion + if (currentSongId == remoteState.songId) { + lastAppliedVersion = remoteSession.stateVersion + } return@onSuccess } softCorrectPosition(connection, remoteSession) - } else if (isNewVersion) { + } else if (isWrongSong || isNewVersion) { applyRemoteState(connection, remoteSession) - lastAppliedVersion = remoteSession.stateVersion + if (currentSongId == remoteState.songId) { + lastAppliedVersion = remoteSession.stateVersion + } return@onSuccess } }.onFailure { @@ -665,17 +714,30 @@ object ListenTogetherSync { "play_index", "jump_to" -> { val targetIndex = json?.optInt("index", -1) ?: -1 val targetSongId = json?.optString("songId") - if (targetIndex >= 0 && targetIndex < player.mediaItemCount) { - player.seekToDefaultPosition(targetIndex) - player.play() - } else if (!targetSongId.isNullOrBlank()) { - for (i in 0 until player.mediaItemCount) { + var match = -1 + val cur = player.currentMediaItemIndex + if (!targetSongId.isNullOrBlank()) { + for (i in (cur + 1).coerceAtLeast(0) until player.mediaItemCount) { if (player.getMediaItemAt(i).mediaId == targetSongId) { - player.seekToDefaultPosition(i) - player.play() + match = i break } } + if (match < 0) { + for (i in 0 until player.mediaItemCount) { + if (player.getMediaItemAt(i).mediaId == targetSongId) { + match = i + break + } + } + } + } else if (targetIndex >= 0) { + val upcomingIdx = cur + 1 + targetIndex + match = if (upcomingIdx in 0 until player.mediaItemCount) upcomingIdx else targetIndex + } + if (match in 0 until player.mediaItemCount) { + player.seekToDefaultPosition(match) + player.play() } } "play_song" -> { @@ -737,16 +799,40 @@ object ListenTogetherSync { "remove_track", "remove_song" -> { val targetIndex = json?.optInt("index", -1) ?: -1 val targetSongId = json?.optString("songId") - if (targetIndex >= 0 && targetIndex < player.mediaItemCount) { - player.removeMediaItem(targetIndex) - } else if (!targetSongId.isNullOrBlank()) { - connection.removeSongFromQueue(targetSongId) + var match = -1 + val cur = player.currentMediaItemIndex + if (!targetSongId.isNullOrBlank()) { + for (i in (cur + 1).coerceAtLeast(0) until player.mediaItemCount) { + if (player.getMediaItemAt(i).mediaId == targetSongId) { + match = i + break + } + } + if (match < 0) { + for (i in 0 until player.mediaItemCount) { + if (player.getMediaItemAt(i).mediaId == targetSongId) { + match = i + break + } + } + } + } else if (targetIndex >= 0) { + val upcomingIdx = cur + 1 + targetIndex + match = if (upcomingIdx in 0 until player.mediaItemCount) upcomingIdx else targetIndex + } + if (match in 0 until player.mediaItemCount) { + player.removeMediaItem(match) } } "move_track" -> { val from = json?.optInt("from", -1) ?: -1 val to = json?.optInt("to", -1) ?: -1 - if (from >= 0 && to >= 0 && from < player.mediaItemCount && to < player.mediaItemCount) { + val cur = player.currentMediaItemIndex + val fromTimeline = cur + 1 + from + val toTimeline = cur + 1 + to + if (fromTimeline in 0 until player.mediaItemCount && toTimeline in 0 until player.mediaItemCount) { + player.moveMediaItem(fromTimeline, toTimeline) + } else if (from in 0 until player.mediaItemCount && to in 0 until player.mediaItemCount) { player.moveMediaItem(from, to) } } @@ -771,13 +857,17 @@ object ListenTogetherSync { if (now > suppressLocalSyncUntil) { if (isWrongSong || isNewVersion) { applyRemoteState(connection, remoteSession) - lastAppliedVersion = remoteSession.stateVersion + if (currentSongId == remoteState.songId) { + lastAppliedVersion = remoteSession.stateVersion + } return@onSuccess } softCorrectPosition(connection, remoteSession) - } else if (isNewVersion) { + } else if (isWrongSong || isNewVersion) { applyRemoteState(connection, remoteSession) - lastAppliedVersion = remoteSession.stateVersion + if (currentSongId == remoteState.songId) { + lastAppliedVersion = remoteSession.stateVersion + } return@onSuccess } }.onFailure { @@ -844,6 +934,50 @@ object ListenTogetherSync { } } + fun publishNow() { + val connection = playerConnection ?: return + val session = _session.value ?: return + if (_isHost.value) { + scope.launch { + if (_connectionMode.value == ListenTogetherConnectionMode.ONLINE) { + publishLocalStateOnline(connection, session) + } else { + syncOnceLan(connection, session) + } + } + } + } + + fun onMediaItemTransitioned(newSongId: String? = null) { + loadingSongId = null + lastSongLoadAttempt = 0L + } + + fun removeLocalSharedQueueItem(index: Int, songId: String? = null) { + val current = _sharedQueue.value.toMutableList() + if (!songId.isNullOrBlank()) { + val idx = current.indexOfFirst { it.songId == songId } + if (idx >= 0) { + current.removeAt(idx) + _sharedQueue.value = current + return + } + } + if (index in 0 until current.size) { + current.removeAt(index) + _sharedQueue.value = current + } + } + + fun moveLocalSharedQueueItem(from: Int, to: Int) { + val current = _sharedQueue.value.toMutableList() + if (from in 0 until current.size && to in 0 until current.size && from != to) { + val item = current.removeAt(from) + current.add(to, item) + _sharedQueue.value = current + } + } + private suspend fun applyRemoteState( connection: PlayerConnection, remoteSession: ListenTogetherSession, @@ -857,12 +991,15 @@ object ListenTogetherSync { val player = connection.player val currentSongId = player.currentMediaItem?.mediaId + val now = System.currentTimeMillis() if (remoteState.songId.isNotBlank()) { if (currentSongId != remoteState.songId) { - if (loadingSongId != remoteState.songId) { + val isStaleLoad = (now - lastSongLoadAttempt) > 4000L + if (loadingSongId != remoteState.songId || isStaleLoad) { loadingSongId = remoteState.songId - suppressLocalSyncUntil = System.currentTimeMillis() + APPLY_GRACE_MS + lastSongLoadAttempt = now + suppressLocalSyncUntil = now + APPLY_GRACE_MS withContext(Dispatchers.Main) { var foundIndex = -1 @@ -964,6 +1101,11 @@ object ListenTogetherSync { connection: PlayerConnection, remoteSession: ListenTogetherSession, ) { + val now = System.currentTimeMillis() + if (now < suppressLocalSyncUntil) { + return + } + val player = connection.player val remoteState = remoteSession.state ?: return val currentSongId = player.currentMediaItem?.mediaId @@ -1118,10 +1260,11 @@ object ListenTogetherSync { "next", "skip_next" -> connection.seekToNext() "previous", "skip_previous" -> connection.seekToPrevious() } + publishNow() return true } - suppressLocalSyncUntil = System.currentTimeMillis() + 650L + suppressLocalSyncUntil = System.currentTimeMillis() + 3000L scope.launch { runCatching { @@ -1168,7 +1311,7 @@ object ListenTogetherSync { state = updatedSession.state?.copy(settings = preservedSettings) ?: updatedSession.state ) lastAppliedVersion = updatedSession.stateVersion - suppressLocalSyncUntil = System.currentTimeMillis() + 650L + suppressLocalSyncUntil = System.currentTimeMillis() + 3000L playerConnection?.let { conn -> scope.launch { delay(100L) @@ -1186,4 +1329,3 @@ object ListenTogetherSync { setDisplayMessage(notice) } } -