MainActivity.kt 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. package com.musicweb.player
  2. import android.annotation.SuppressLint
  3. import android.content.BroadcastReceiver
  4. import android.content.Context
  5. import android.content.Intent
  6. import android.content.IntentFilter
  7. import android.os.Bundle
  8. import android.view.ViewGroup
  9. import android.webkit.JavascriptInterface
  10. import android.webkit.WebChromeClient
  11. import android.webkit.WebResourceRequest
  12. import android.webkit.WebSettings
  13. import android.webkit.WebView
  14. import android.webkit.WebViewClient
  15. import android.widget.EditText
  16. import android.widget.LinearLayout
  17. import androidx.appcompat.app.AppCompatActivity
  18. import androidx.core.content.ContextCompat
  19. import com.google.android.material.dialog.MaterialAlertDialogBuilder
  20. import com.musicweb.player.player.PlayerService
  21. class MainActivity : AppCompatActivity() {
  22. private lateinit var webView: WebView
  23. private val prefs by lazy { getSharedPreferences("musicweb_apk", MODE_PRIVATE) }
  24. private val playbackReceiver = object : BroadcastReceiver() {
  25. override fun onReceive(context: Context?, intent: Intent?) {
  26. if (intent?.action != PlayerService.ACTION_PLAYBACK_STATE || !this@MainActivity::webView.isInitialized) return
  27. val isPlaying = intent.getBooleanExtra(PlayerService.EXTRA_IS_PLAYING, false)
  28. val position = intent.getLongExtra(PlayerService.EXTRA_POSITION, 0L)
  29. val duration = intent.getLongExtra(PlayerService.EXTRA_DURATION, 0L)
  30. val index = intent.getIntExtra(PlayerService.EXTRA_CURRENT_INDEX, -1)
  31. val name = intent.getStringExtra(PlayerService.EXTRA_TRACK_NAME).orEmpty()
  32. val path = intent.getStringExtra(PlayerService.EXTRA_TRACK_PATH).orEmpty()
  33. val error = intent.getStringExtra(PlayerService.EXTRA_ERROR).orEmpty()
  34. runOnUiThread {
  35. val script = """
  36. window.__musicwebNativeUpdate && window.__musicwebNativeUpdate({
  37. isPlaying: $isPlaying,
  38. position: $position,
  39. duration: $duration,
  40. index: $index,
  41. name: ${name.quoteForJs()},
  42. path: ${path.quoteForJs()},
  43. error: ${error.quoteForJs()}
  44. });
  45. """.trimIndent()
  46. webView.evaluateJavascript(script, null)
  47. }
  48. }
  49. }
  50. @SuppressLint("SetJavaScriptEnabled")
  51. override fun onCreate(savedInstanceState: Bundle?) {
  52. super.onCreate(savedInstanceState)
  53. webView = WebView(this).apply {
  54. layoutParams = ViewGroup.LayoutParams(
  55. ViewGroup.LayoutParams.MATCH_PARENT,
  56. ViewGroup.LayoutParams.MATCH_PARENT
  57. )
  58. settings.apply {
  59. javaScriptEnabled = true
  60. domStorageEnabled = true
  61. cacheMode = WebSettings.LOAD_DEFAULT
  62. mediaPlaybackRequiresUserGesture = false
  63. mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
  64. allowFileAccess = true
  65. allowContentAccess = true
  66. }
  67. WebView.setWebContentsDebuggingEnabled(true)
  68. addJavascriptInterface(Bridge(), "AndroidPlayer")
  69. webViewClient = object : WebViewClient() {
  70. override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?) = false
  71. override fun onPageFinished(view: WebView?, url: String?) {
  72. super.onPageFinished(view, url)
  73. injectBridge()
  74. }
  75. }
  76. webChromeClient = WebChromeClient()
  77. }
  78. setContentView(webView)
  79. if (prefs.getString(KEY_BASE_URL, null).isNullOrBlank()) {
  80. promptForUrl()
  81. } else {
  82. webView.loadUrl(requireBaseUrl())
  83. }
  84. }
  85. override fun onBackPressed() {
  86. if (this::webView.isInitialized && webView.canGoBack()) webView.goBack() else super.onBackPressed()
  87. }
  88. override fun onStart() {
  89. super.onStart()
  90. ContextCompat.registerReceiver(
  91. this,
  92. playbackReceiver,
  93. IntentFilter(PlayerService.ACTION_PLAYBACK_STATE),
  94. ContextCompat.RECEIVER_NOT_EXPORTED
  95. )
  96. }
  97. override fun onStop() {
  98. unregisterReceiver(playbackReceiver)
  99. super.onStop()
  100. }
  101. private fun requireBaseUrl(): String {
  102. return prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL)?.trim().orEmpty()
  103. .ifBlank { DEFAULT_BASE_URL }
  104. .trimEnd('/')
  105. }
  106. private fun promptForUrl() {
  107. val input = EditText(this).apply {
  108. setText(DEFAULT_BASE_URL)
  109. setSelection(text.length)
  110. }
  111. val container = LinearLayout(this).apply {
  112. setPadding(48, 32, 48, 0)
  113. addView(input)
  114. }
  115. MaterialAlertDialogBuilder(this)
  116. .setTitle(R.string.set_server_title)
  117. .setMessage(R.string.set_server_message)
  118. .setView(container)
  119. .setCancelable(false)
  120. .setPositiveButton(android.R.string.ok) { _, _ ->
  121. val url = input.text?.toString()?.trim().orEmpty().ifBlank { DEFAULT_BASE_URL }
  122. prefs.edit().putString(KEY_BASE_URL, url).apply()
  123. webView.loadUrl(url.trimEnd('/'))
  124. }
  125. .show()
  126. }
  127. private fun injectBridge() {
  128. val script = """
  129. (function() {
  130. if (window.__musicwebNativeBridgeInstalled) return;
  131. window.__musicwebNativeBridgeInstalled = true;
  132. const absoluteUrl = (url) => {
  133. try {
  134. const parsed = new URL(url || "", window.location.href);
  135. parsed.pathname = parsed.pathname
  136. .split('/')
  137. .map(part => encodeURIComponent(decodeURIComponent(part)))
  138. .join('/');
  139. return parsed.toString();
  140. } catch (e) {
  141. return url || "";
  142. }
  143. };
  144. const toQueueJson = () => JSON.stringify((state.currentQueue || []).map(t => ({
  145. path: t.path || "",
  146. url: absoluteUrl(t.url || ""),
  147. name: t.name || "",
  148. folder: t.folder || ""
  149. })));
  150. const updateNativeUi = (index) => {
  151. if (!state.currentQueue.length) return;
  152. state.currentTrackIndex = index;
  153. const track = state.currentQueue[index];
  154. if (typeof updateDock === 'function') updateDock(track);
  155. if (typeof renderQueue === 'function') renderQueue();
  156. if (typeof savePlaybackState === 'function') savePlaybackState();
  157. };
  158. const sync = () => {
  159. if (window.AndroidPlayer && state.currentQueue && state.currentQueue.length) {
  160. AndroidPlayer.setQueue(toQueueJson(), Number(state.currentTrackIndex || 0));
  161. }
  162. };
  163. const formatNativeTime = (ms) => {
  164. const total = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
  165. const minutes = Math.floor(total / 60);
  166. const seconds = total % 60;
  167. return String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0');
  168. };
  169. window.__musicwebNativeUpdate = function(payload) {
  170. const playing = !!payload.isPlaying;
  171. const toggle = document.getElementById('playToggleBtn');
  172. const playIcon = document.querySelector('.icon-play');
  173. const pauseIcon = document.querySelector('.icon-pause');
  174. if (toggle) {
  175. toggle.classList.toggle('is-playing', playing);
  176. toggle.dataset.tip = playing ? '暂停' : '播放';
  177. }
  178. if (playIcon) playIcon.classList.toggle('is-hidden', playing);
  179. if (pauseIcon) pauseIcon.classList.toggle('is-hidden', !playing);
  180. if (payload.index >= 0 && state.currentQueue && state.currentQueue[payload.index]) {
  181. state.currentTrackIndex = payload.index;
  182. if (typeof renderQueue === 'function') renderQueue();
  183. }
  184. const duration = Number(payload.duration || 0);
  185. const position = Number(payload.position || 0);
  186. const progress = duration > 0 ? (position / duration) * 100 : 0;
  187. const bar = document.getElementById('progressBar');
  188. if (bar) bar.value = String(Math.max(0, Math.min(100, progress)));
  189. const currentLabel = document.getElementById('currentTimeLabel');
  190. const durationLabel = document.getElementById('durationLabel');
  191. if (currentLabel) currentLabel.textContent = formatNativeTime(position);
  192. if (durationLabel) durationLabel.textContent = formatNativeTime(duration);
  193. if (payload.name) {
  194. const title = document.getElementById('dockTitle');
  195. const dockPath = document.getElementById('dockPath');
  196. const nowTitle = document.getElementById('nowTitle');
  197. const nowMeta = document.getElementById('nowMeta');
  198. if (title) title.textContent = payload.name;
  199. if (dockPath) dockPath.textContent = payload.path || '';
  200. if (nowTitle) nowTitle.textContent = payload.name;
  201. if (nowMeta) nowMeta.textContent = payload.path || '';
  202. }
  203. };
  204. window.startQueue = function(tracks, index) {
  205. if (!tracks || !tracks.length) return;
  206. state.currentQueue = [...tracks];
  207. updateNativeUi(index);
  208. AndroidPlayer.playQueue(toQueueJson(), Number(state.currentTrackIndex || 0));
  209. };
  210. window.playTrack = function(index) {
  211. if (!state.currentQueue.length) return;
  212. updateNativeUi(index);
  213. AndroidPlayer.playQueue(toQueueJson(), Number(state.currentTrackIndex || 0));
  214. };
  215. window.playPrevious = function() { AndroidPlayer.previous(); };
  216. window.playNext = function() { AndroidPlayer.next(); };
  217. window.nextTrack = function() { AndroidPlayer.next(); };
  218. const playBtn = document.getElementById('playToggleBtn');
  219. if (playBtn && !playBtn.dataset.nativeBound) {
  220. playBtn.dataset.nativeBound = '1';
  221. playBtn.onclick = function() { AndroidPlayer.toggle(); };
  222. }
  223. const prevBtn = document.getElementById('prevBtn');
  224. if (prevBtn && !prevBtn.dataset.nativeBound) {
  225. prevBtn.dataset.nativeBound = '1';
  226. prevBtn.onclick = function() { AndroidPlayer.previous(); };
  227. }
  228. const nextBtn = document.getElementById('nextBtn');
  229. if (nextBtn && !nextBtn.dataset.nativeBound) {
  230. nextBtn.dataset.nativeBound = '1';
  231. nextBtn.onclick = function() { AndroidPlayer.next(); };
  232. }
  233. sync();
  234. })();
  235. """.trimIndent()
  236. webView.evaluateJavascript(script, null)
  237. }
  238. inner class Bridge {
  239. @JavascriptInterface
  240. fun setQueue(queueJson: String, index: Int) {
  241. val intent = Intent(this@MainActivity, PlayerService::class.java).apply {
  242. action = PlayerService.ACTION_SET_QUEUE
  243. putExtra(PlayerService.EXTRA_QUEUE_JSON, queueJson)
  244. putExtra(PlayerService.EXTRA_INDEX, index)
  245. }
  246. startService(intent)
  247. }
  248. @JavascriptInterface
  249. fun playQueue(queueJson: String, index: Int) {
  250. val intent = Intent(this@MainActivity, PlayerService::class.java).apply {
  251. action = PlayerService.ACTION_PLAY_QUEUE
  252. putExtra(PlayerService.EXTRA_QUEUE_JSON, queueJson)
  253. putExtra(PlayerService.EXTRA_INDEX, index)
  254. }
  255. ContextCompat.startForegroundService(this@MainActivity, intent)
  256. }
  257. @JavascriptInterface
  258. fun toggle() {
  259. startService(Intent(this@MainActivity, PlayerService::class.java).apply {
  260. action = PlayerService.ACTION_TOGGLE
  261. })
  262. }
  263. @JavascriptInterface
  264. fun next() {
  265. startService(Intent(this@MainActivity, PlayerService::class.java).apply {
  266. action = PlayerService.ACTION_NEXT
  267. })
  268. }
  269. @JavascriptInterface
  270. fun previous() {
  271. startService(Intent(this@MainActivity, PlayerService::class.java).apply {
  272. action = PlayerService.ACTION_PREVIOUS
  273. })
  274. }
  275. }
  276. companion object {
  277. private const val KEY_BASE_URL = "base_url"
  278. private const val DEFAULT_BASE_URL = "http://110.42.102.94:8006/"
  279. }
  280. }
  281. private fun String.quoteForJs(): String {
  282. return buildString {
  283. append('"')
  284. this@quoteForJs.forEach { char ->
  285. when (char) {
  286. '\\' -> append("\\\\")
  287. '"' -> append("\\\"")
  288. '\n' -> append("\\n")
  289. '\r' -> append("\\r")
  290. '\t' -> append("\\t")
  291. else -> append(char)
  292. }
  293. }
  294. append('"')
  295. }
  296. }