Skip to content

Commit 623888c

Browse files
namvh-gsclaude
andcommitted
DriveTune 2.2.0 — bỏ auto download, chặn quảng cáo tận gốc, nút vô lăng, tìm bằng giọng nói
Bỏ tính năng lưu offline Phần tải ngầm không chạy được như mong đợi nên gỡ toàn bộ: OfflineStore, OfflineDownloader, OfflinePlayer, công tắc + danh sách đã tải trong Cài đặt. Bảng Cài đặt giờ chỉ còn mục giới thiệu. Chặn quảng cáo: thêm lớp cắt dữ liệu (AdBlocker.EARLY_JS) Trước đây chỉ tắt tiếng rồi tua — quảng cáo vẫn chạy hết. Giờ xoá thẳng adPlacements/playerAds/adSlots/adConfig khỏi phản hồi /youtubei/v1/player TRƯỚC khi trang đọc, nên trình phát không có gì để chèn, giống cách Brave làm. Vá cả ba đường vào: ytInitialPlayerResponse (setter), fetch (bọc lại Response), XMLHttpRequest (định nghĩa getter từ open() nên không phụ thuộc thứ tự listener của trang). Chạy ở document-start, không phải onPageFinished. Lớp DOM dọn phần lọt lưới: nhắm đúng <video> của trình phát thay vì thẻ <video> đầu tiên, nhận diện ad bằng getAdState(), không skip được thì tua thẳng tới duration để vào bài ngay. Tự phát lại khi trang tự dừng (PlaybackGuard) Bản cũ coi MỌI thao tác chạm trong 1,5 giây trước khi dừng là do người dùng — cuộn danh sách hay chạm nhầm đúng lúc trang tự dừng là nhạc tắt luôn, vì cờ __ytaWantPlay hạ xuống rồi không có gì nâng lại. Giờ chỉ tính là chủ động khi cú chạm rơi đúng vào nút phát/tạm dừng (hit-test bằng closest) hoặc phím k/space. Thêm hai lớp hồi phục: kẹt buffering quá 15 giây thì tua lùi ép nạp lại, hết bài mà trang không chuyển thì bấm hộ nút bài kế. Giành lại audio focus (sau chỉ đường Maps, cuộc gọi) cũng phát tiếp, nhưng vẫn tôn trọng nếu người dùng đang chủ động tạm dừng. Nút chuyển bài trên vô lăng Phiên media chuyển từ BackgroundPlaybackService sang MediaSessionHolder sống suốt vòng đời app. Trước đây service chỉ chạy khi app xuống nền, nên đúng lúc app hiện trên màn hình Android Auto thì không có phiên nào nhận phím. Phủ ba đường: transport control, ACTION_MEDIA_BUTTON (map thêm FAST_FORWARD/REWIND thành chuyển bài), và dispatchKeyEvent cho head unit gửi phím cứng thẳng tới cửa sổ. Tìm bằng giọng nói Thêm nút mic trên bong bóng nổi (đang mở Maps vẫn tìm được) và phím "Nói" trên bàn phím xe. Cả hai đi qua EXTRA_START_VOICE, mở hộp thoại đọc ngay khi app lên foreground. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 94e549f commit 623888c

18 files changed

Lines changed: 673 additions & 922 deletions

‎app/build.gradle.kts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ android {
1616
applicationId = "com.gsvn.aamusic"
1717
minSdk = 35
1818
targetSdk = 36
19-
versionCode = 19
20-
versionName = "2.1.0"
19+
versionCode = 20
20+
versionName = "2.2.0"
2121

2222
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2323
}

‎app/src/main/java/com/gsvn/aamusic/BackgroundPlaybackService.kt‎

Lines changed: 27 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,6 @@ import android.os.IBinder
1515
import android.os.Looper
1616
import android.os.PowerManager
1717
import android.provider.Settings
18-
import android.support.v4.media.MediaMetadataCompat
19-
import android.support.v4.media.session.MediaSessionCompat
20-
import android.support.v4.media.session.PlaybackStateCompat
2118
import android.view.Gravity
2219
import android.view.LayoutInflater
2320
import android.view.MotionEvent
@@ -31,6 +28,7 @@ import android.widget.TextView
3128
import androidx.core.app.NotificationCompat
3229
import androidx.core.content.ContextCompat
3330
import androidx.media.app.NotificationCompat as MediaStyleNotif
31+
import com.gsvn.aamusic.player.MediaSessionHolder
3432
import com.gsvn.aamusic.player.PlayerController
3533
import kotlin.math.abs
3634
import kotlin.math.hypot
@@ -62,28 +60,37 @@ class BackgroundPlaybackService : Service() {
6260
private var screenW = 0
6361
private var screenH = 0
6462

65-
// Native media session so the background-playback notification shows the
66-
// app's own branding (icon/title/controls) instead of YouTube's artwork.
67-
private var mediaSession: MediaSessionCompat? = null
68-
private var lastTitle: String? = null
69-
private var lastPlaying: Boolean? = null
63+
// Notification hiển thị thương hiệu của app thay vì artwork của YouTube.
64+
// Phiên media nằm ở [MediaSessionHolder] (sống suốt vòng đời app), ở đây
65+
// chỉ mượn token để dựng MediaStyle.
7066
private val artwork: Bitmap by lazy { buildArtwork() }
7167

68+
// Phiên media có thể đã đúng sẵn (activity cũng poll), nhưng notification
69+
// của service thì chưa — nên dò trùng riêng, đừng dùng chung với phiên.
70+
private var lastNotifTitle: String? = null
71+
private var lastNotifPlaying: Boolean? = null
72+
7273
private val handler = Handler(Looper.getMainLooper())
7374
private var polling = false
7475

7576
private val statedPoll = object : Runnable {
7677
override fun run() {
7778
if (!polling) return
7879
PlayerController.queryState { title, playing ->
79-
titleView?.text = title.ifBlank { getString(R.string.bubble_playing) }
80+
val shownTitle = title.ifBlank { getString(R.string.bubble_playing) }
81+
titleView?.text = shownTitle
8082
playPauseBtn?.setImageResource(
8183
if (playing) R.drawable.ic_pause else R.drawable.ic_play
8284
)
83-
if (title != lastTitle || playing != lastPlaying) {
84-
lastTitle = title
85-
lastPlaying = playing
86-
updateMediaState(title, playing)
85+
MediaSessionHolder.update(shownTitle, playing, artwork)
86+
87+
if (shownTitle != lastNotifTitle || playing != lastNotifPlaying) {
88+
lastNotifTitle = shownTitle
89+
lastNotifPlaying = playing
90+
runCatching {
91+
getSystemService(NotificationManager::class.java)
92+
.notify(NOTIFICATION_ID, buildNotification(shownTitle, playing))
93+
}
8794
}
8895
}
8996
handler.postDelayed(this, POLL_INTERVAL_MS)
@@ -93,7 +100,8 @@ class BackgroundPlaybackService : Service() {
93100
override fun onCreate() {
94101
super.onCreate()
95102
createNotificationChannel()
96-
setupMediaSession()
103+
MediaSessionHolder.ensure(this)
104+
MediaSessionHolder.onStopRequested = { stopEverything() }
97105
acquireWakeLock()
98106
}
99107

@@ -116,8 +124,9 @@ class BackgroundPlaybackService : Service() {
116124
override fun onDestroy() {
117125
stopPolling()
118126
removeBubble()
119-
mediaSession?.release()
120-
mediaSession = null
127+
lastNotifTitle = null
128+
lastNotifPlaying = null
129+
MediaSessionHolder.onStopRequested = null
121130
releaseWakeLock()
122131
super.onDestroy()
123132
}
@@ -167,6 +176,7 @@ class BackgroundPlaybackService : Service() {
167176
root.findViewById<ImageButton>(R.id.btnPrev).setOnClickListener { PlayerController.previous() }
168177
playPauseBtn?.setOnClickListener { PlayerController.playPause() }
169178
root.findViewById<ImageButton>(R.id.btnNext).setOnClickListener { PlayerController.next() }
179+
root.findViewById<ImageButton>(R.id.btnVoice).setOnClickListener { PlayerController.openVoiceSearch() }
170180
root.findViewById<ImageButton>(R.id.btnSearch).setOnClickListener { PlayerController.openSearch() }
171181
root.findViewById<ImageButton>(R.id.btnOpen).setOnClickListener { PlayerController.openApp() }
172182
root.findViewById<ImageButton>(R.id.btnCollapse).setOnClickListener { collapse() }
@@ -353,54 +363,6 @@ class BackgroundPlaybackService : Service() {
353363
manager.createNotificationChannel(channel)
354364
}
355365

356-
private fun setupMediaSession() {
357-
mediaSession = MediaSessionCompat(this, "AAMusic").apply {
358-
setCallback(object : MediaSessionCompat.Callback() {
359-
override fun onPlay() { PlayerController.playPause() }
360-
override fun onPause() { PlayerController.playPause() }
361-
override fun onSkipToNext() { PlayerController.next() }
362-
override fun onSkipToPrevious() { PlayerController.previous() }
363-
override fun onStop() { stopEverything() }
364-
})
365-
isActive = true
366-
}
367-
}
368-
369-
/** Pushes the current title/state into the session + re-posts the notification. */
370-
private fun updateMediaState(title: String, playing: Boolean) {
371-
val session = mediaSession ?: return
372-
val shownTitle = title.ifBlank { getString(R.string.bubble_playing) }
373-
374-
session.setMetadata(
375-
MediaMetadataCompat.Builder()
376-
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, shownTitle)
377-
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, getString(R.string.app_name))
378-
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, artwork)
379-
.build()
380-
)
381-
session.setPlaybackState(
382-
PlaybackStateCompat.Builder()
383-
.setActions(
384-
PlaybackStateCompat.ACTION_PLAY or
385-
PlaybackStateCompat.ACTION_PAUSE or
386-
PlaybackStateCompat.ACTION_PLAY_PAUSE or
387-
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
388-
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
389-
PlaybackStateCompat.ACTION_STOP
390-
)
391-
.setState(
392-
if (playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
393-
PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN,
394-
1f
395-
)
396-
.build()
397-
)
398-
runCatching {
399-
getSystemService(NotificationManager::class.java)
400-
.notify(NOTIFICATION_ID, buildNotification(shownTitle, playing))
401-
}
402-
}
403-
404366
private fun servicePending(action: String, requestCode: Int): PendingIntent {
405367
val intent = Intent(this, BackgroundPlaybackService::class.java).setAction(action)
406368
return PendingIntent.getService(
@@ -457,7 +419,7 @@ class BackgroundPlaybackService : Service() {
457419
.addAction(R.drawable.ic_close, "Stop", servicePending(ACTION_STOP, 1))
458420
.setStyle(
459421
MediaStyleNotif.MediaStyle()
460-
.setMediaSession(mediaSession?.sessionToken)
422+
.setMediaSession(MediaSessionHolder.sessionToken)
461423
.setShowActionsInCompactView(0, 1, 2)
462424
)
463425
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)

‎app/src/main/java/com/gsvn/aamusic/MainActivity.kt‎

Lines changed: 46 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import android.os.Looper
1818
import android.provider.Settings
1919
import android.speech.RecognizerIntent
2020
import android.view.Display
21+
import android.view.KeyEvent
2122
import android.util.TypedValue
2223
import android.view.Gravity
2324
import android.view.LayoutInflater
@@ -49,9 +50,7 @@ import com.google.android.material.color.DynamicColors
4950
import com.gsvn.aamusic.data.SearchHistory
5051
import com.gsvn.aamusic.data.SearchSuggest
5152
import com.gsvn.aamusic.databinding.ActivityMainBinding
52-
import com.gsvn.aamusic.offline.OfflineDownloader
53-
import com.gsvn.aamusic.offline.OfflinePlayer
54-
import com.gsvn.aamusic.offline.OfflineStore
53+
import com.gsvn.aamusic.player.MediaSessionHolder
5554
import com.gsvn.aamusic.player.PlayerController
5655
import com.gsvn.aamusic.ui.SettingsSheet
5756
import com.gsvn.aamusic.web.BrowserCallbacks
@@ -68,12 +67,17 @@ class MainActivity : AppCompatActivity() {
6867
private var pendingPermissionRequest: PermissionRequest? = null
6968
private var isBackgroundPlaybackActive: Boolean = false
7069

71-
// ── Lưu offline ─────────────────────────────────────────────────
72-
private val offlineHandler = Handler(Looper.getMainLooper())
73-
private val offlineTicker = object : Runnable {
70+
// ── Phiên media ─────────────────────────────────────────────────
71+
// Khi app đang hiển thị (kể cả trên màn hình xe) thì service nền không
72+
// chạy, nên chính activity phải bơm trạng thái vào phiên media — hệ thống
73+
// chỉ định tuyến phím vô lăng tới phiên media đang hoạt động.
74+
private val mediaHandler = Handler(Looper.getMainLooper())
75+
private val mediaStateTicker = object : Runnable {
7476
override fun run() {
75-
tryCaptureOffline()
76-
offlineHandler.postDelayed(this, OFFLINE_CHECK_MS)
77+
PlayerController.queryState { title, playing ->
78+
MediaSessionHolder.update(title.ifBlank { getString(R.string.bubble_playing) }, playing)
79+
}
80+
mediaHandler.postDelayed(this, MEDIA_STATE_POLL_MS)
7781
}
7882
}
7983

@@ -100,7 +104,15 @@ class MainActivity : AppCompatActivity() {
100104

101105
private val audioFocusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
102106
when (focusChange) {
103-
AudioManager.AUDIOFOCUS_GAIN -> webView?.onResume()
107+
// Chỉ đường dẫn của Maps, cuộc gọi… cướp focus làm trang tự dừng;
108+
// giành lại được thì phát tiếp, người dùng không phải bấm gì.
109+
AudioManager.AUDIOFOCUS_GAIN,
110+
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
111+
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> {
112+
webView?.onResume()
113+
// Trang cần một nhịp để nhả lại luồng audio trước khi play() ăn.
114+
mediaHandler.postDelayed({ PlayerController.resume() }, RESUME_AFTER_FOCUS_MS)
115+
}
104116
else -> {
105117
// Keep playing — BackgroundPlaybackService keeps the WebView alive
106118
// and YouTube Music manages its own volume.
@@ -113,7 +125,6 @@ class MainActivity : AppCompatActivity() {
113125
if (intent?.action == BackgroundPlaybackService.ACTION_STOP_PLAYBACK) {
114126
isBackgroundPlaybackActive = false
115127
webView?.onPause()
116-
if (OfflinePlayer.isActive) OfflinePlayer.stop()
117128
}
118129
}
119130
}
@@ -138,6 +149,7 @@ class MainActivity : AppCompatActivity() {
138149

139150
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
140151
requestAudioFocus()
152+
MediaSessionHolder.ensure(this)
141153

142154
setupWebView()
143155
setupSearchBar()
@@ -150,12 +162,18 @@ class MainActivity : AppCompatActivity() {
150162
RECEIVER_NOT_EXPORTED
151163
)
152164

153-
// Chạy cả khi app ở nền: bài tự chuyển lúc màn hình tắt vẫn được lưu.
154-
offlineHandler.postDelayed(offlineTicker, OFFLINE_CHECK_MS)
155-
156165
handleSearchIntent(intent)
157166
}
158167

168+
/**
169+
* Một số head unit gửi phím chuyển bài trên vô lăng thẳng tới cửa sổ đang
170+
* hiển thị thay vì qua phiên media. Bắt ở đây trước khi WebView nuốt mất.
171+
*/
172+
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
173+
if (MediaSessionHolder.handleKeyEvent(event)) return true
174+
return super.dispatchKeyEvent(event)
175+
}
176+
159177
override fun onNewIntent(intent: Intent) {
160178
super.onNewIntent(intent)
161179
setIntent(intent)
@@ -197,9 +215,12 @@ class MainActivity : AppCompatActivity() {
197215
webView?.onResume()
198216
}
199217
updateOverlayButton()
218+
mediaHandler.removeCallbacks(mediaStateTicker)
219+
mediaHandler.post(mediaStateTicker)
200220
}
201221

202222
override fun onPause() {
223+
mediaHandler.removeCallbacks(mediaStateTicker)
203224
exitFullscreen()
204225
isBackgroundPlaybackActive = true
205226
BackgroundPlaybackService.start(this)
@@ -213,7 +234,8 @@ class MainActivity : AppCompatActivity() {
213234
abandonAudioFocus()
214235
runCatching { unregisterReceiver(stopPlaybackReceiver) }
215236
exitFullscreen()
216-
offlineHandler.removeCallbacks(offlineTicker)
237+
mediaHandler.removeCallbacksAndMessages(null)
238+
MediaSessionHolder.release()
217239
PlayerController.unregister()
218240
binding.webView.releaseCompletely()
219241
webView = null
@@ -246,8 +268,7 @@ class MainActivity : AppCompatActivity() {
246268
binding.searchInput.requestFocus()
247269
binding.searchInput.post { showKeyboard() }
248270
}
249-
},
250-
onPageFinished = { runOnUiThread { tryCaptureOffline() } }
271+
}
251272
)
252273

253274
webView = binding.webView
@@ -258,28 +279,6 @@ class MainActivity : AppCompatActivity() {
258279
}
259280
}
260281

261-
// ── Lưu offline ────────────────────────────────────────────────
262-
263-
/**
264-
* Thử lưu bài đang phát. Tự bỏ qua khi công tắc tắt, bài đã có sẵn, hoặc
265-
* trang không trả về URL luồng audio (bài dùng signatureCipher).
266-
*/
267-
private fun tryCaptureOffline() {
268-
val view = webView ?: return
269-
if (!OfflineStore.isEnabled(this)) return
270-
OfflineDownloader.captureCurrent(view, this) { result ->
271-
val message = when (result) {
272-
is OfflineDownloader.Result.Started ->
273-
getString(R.string.offline_saving, result.title)
274-
is OfflineDownloader.Result.Saved ->
275-
getString(R.string.offline_saved, result.track.title)
276-
is OfflineDownloader.Result.Failed ->
277-
getString(R.string.offline_failed, result.title, result.reason)
278-
}
279-
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
280-
}
281-
}
282-
283282
// ── Search bar ─────────────────────────────────────────────────
284283

285284
private fun setupSearchBar() {
@@ -348,6 +347,7 @@ class MainActivity : AppCompatActivity() {
348347
binding.carKeyboard.onSearch = {
349348
submitSearch(binding.searchInput.text?.toString().orEmpty())
350349
}
350+
binding.carKeyboard.onVoice = { startVoiceSearch() }
351351
// Keep the system IME from popping (and rendering tiny) on the car screen.
352352
if (isCarDisplay()) binding.searchInput.showSoftInputOnFocus = false
353353
}
@@ -570,6 +570,9 @@ class MainActivity : AppCompatActivity() {
570570
if (!query.isNullOrBlank()) {
571571
binding.searchInput.setText(query)
572572
submitSearch(query)
573+
} else if (intent.getBooleanExtra(EXTRA_START_VOICE, false)) {
574+
// Mở thẳng hộp thoại đọc: đang lái thì không gõ được.
575+
startVoiceSearch()
573576
} else if (intent.getBooleanExtra(EXTRA_FOCUS_SEARCH, false)) {
574577
showSuggestions()
575578
binding.searchInput.requestFocus()
@@ -794,14 +797,17 @@ class MainActivity : AppCompatActivity() {
794797
// playback still runs audio-only in the background as usual.
795798
private const val HOME_URL = "https://www.youtube.com"
796799
private const val SEARCH_URL = "https://www.youtube.com/results?search_query="
797-
/** Nhịp dò bài đang phát; trang điều hướng kiểu SPA không báo page load. */
798-
private const val OFFLINE_CHECK_MS = 20_000L
800+
801+
/** Nhịp bơm tên bài + trạng thái vào phiên media khi app đang hiển thị. */
802+
private const val MEDIA_STATE_POLL_MS = 1_000L
803+
private const val RESUME_AFTER_FOCUS_MS = 600L
799804

800805
private const val RC_AUDIO = 1102
801806
private const val RC_STARTUP_PERMISSIONS = 1103
802807

803808
const val ACTION_SEARCH = "com.gsvn.aamusic.action.SEARCH"
804809
const val EXTRA_QUERY = "com.gsvn.aamusic.extra.QUERY"
805810
const val EXTRA_FOCUS_SEARCH = "com.gsvn.aamusic.extra.FOCUS_SEARCH"
811+
const val EXTRA_START_VOICE = "com.gsvn.aamusic.extra.START_VOICE"
806812
}
807813
}

0 commit comments

Comments
 (0)