package com.musicweb.player import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Bundle import android.view.ViewGroup import android.webkit.JavascriptInterface import android.webkit.WebChromeClient import android.webkit.WebResourceRequest import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import android.widget.EditText import android.widget.LinearLayout import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.musicweb.player.player.PlayerService class MainActivity : AppCompatActivity() { private lateinit var webView: WebView private val prefs by lazy { getSharedPreferences("musicweb_apk", MODE_PRIVATE) } private val playbackReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action != PlayerService.ACTION_PLAYBACK_STATE || !this@MainActivity::webView.isInitialized) return val isPlaying = intent.getBooleanExtra(PlayerService.EXTRA_IS_PLAYING, false) val position = intent.getLongExtra(PlayerService.EXTRA_POSITION, 0L) val duration = intent.getLongExtra(PlayerService.EXTRA_DURATION, 0L) val index = intent.getIntExtra(PlayerService.EXTRA_CURRENT_INDEX, -1) val name = intent.getStringExtra(PlayerService.EXTRA_TRACK_NAME).orEmpty() val path = intent.getStringExtra(PlayerService.EXTRA_TRACK_PATH).orEmpty() val error = intent.getStringExtra(PlayerService.EXTRA_ERROR).orEmpty() runOnUiThread { val script = """ window.__musicwebNativeUpdate && window.__musicwebNativeUpdate({ isPlaying: $isPlaying, position: $position, duration: $duration, index: $index, name: ${name.quoteForJs()}, path: ${path.quoteForJs()}, error: ${error.quoteForJs()} }); """.trimIndent() webView.evaluateJavascript(script, null) } } } @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) webView = WebView(this).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) settings.apply { javaScriptEnabled = true domStorageEnabled = true cacheMode = WebSettings.LOAD_DEFAULT mediaPlaybackRequiresUserGesture = false mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW allowFileAccess = true allowContentAccess = true } WebView.setWebContentsDebuggingEnabled(true) addJavascriptInterface(Bridge(), "AndroidPlayer") webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?) = false override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) injectBridge() } } webChromeClient = WebChromeClient() } setContentView(webView) if (prefs.getString(KEY_BASE_URL, null).isNullOrBlank()) { promptForUrl() } else { webView.loadUrl(requireBaseUrl()) } } override fun onBackPressed() { if (this::webView.isInitialized && webView.canGoBack()) webView.goBack() else super.onBackPressed() } override fun onStart() { super.onStart() ContextCompat.registerReceiver( this, playbackReceiver, IntentFilter(PlayerService.ACTION_PLAYBACK_STATE), ContextCompat.RECEIVER_NOT_EXPORTED ) } override fun onStop() { unregisterReceiver(playbackReceiver) super.onStop() } private fun requireBaseUrl(): String { return prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL)?.trim().orEmpty() .ifBlank { DEFAULT_BASE_URL } .trimEnd('/') } private fun promptForUrl() { val input = EditText(this).apply { setText(DEFAULT_BASE_URL) setSelection(text.length) } val container = LinearLayout(this).apply { setPadding(48, 32, 48, 0) addView(input) } MaterialAlertDialogBuilder(this) .setTitle(R.string.set_server_title) .setMessage(R.string.set_server_message) .setView(container) .setCancelable(false) .setPositiveButton(android.R.string.ok) { _, _ -> val url = input.text?.toString()?.trim().orEmpty().ifBlank { DEFAULT_BASE_URL } prefs.edit().putString(KEY_BASE_URL, url).apply() webView.loadUrl(url.trimEnd('/')) } .show() } private fun injectBridge() { val script = """ (function() { if (window.__musicwebNativeBridgeInstalled) return; window.__musicwebNativeBridgeInstalled = true; const absoluteUrl = (url) => { try { const parsed = new URL(url || "", window.location.href); parsed.pathname = parsed.pathname .split('/') .map(part => encodeURIComponent(decodeURIComponent(part))) .join('/'); return parsed.toString(); } catch (e) { return url || ""; } }; const toQueueJson = () => JSON.stringify((state.currentQueue || []).map(t => ({ path: t.path || "", url: absoluteUrl(t.url || ""), name: t.name || "", folder: t.folder || "" }))); const updateNativeUi = (index) => { if (!state.currentQueue.length) return; state.currentTrackIndex = index; const track = state.currentQueue[index]; if (typeof updateDock === 'function') updateDock(track); if (typeof renderQueue === 'function') renderQueue(); if (typeof savePlaybackState === 'function') savePlaybackState(); }; const sync = () => { if (window.AndroidPlayer && state.currentQueue && state.currentQueue.length) { AndroidPlayer.setQueue(toQueueJson(), Number(state.currentTrackIndex || 0)); } }; const formatNativeTime = (ms) => { const total = Math.max(0, Math.floor((Number(ms) || 0) / 1000)); const minutes = Math.floor(total / 60); const seconds = total % 60; return String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0'); }; window.__musicwebNativeUpdate = function(payload) { const playing = !!payload.isPlaying; const toggle = document.getElementById('playToggleBtn'); const playIcon = document.querySelector('.icon-play'); const pauseIcon = document.querySelector('.icon-pause'); if (toggle) { toggle.classList.toggle('is-playing', playing); toggle.dataset.tip = playing ? '暂停' : '播放'; } if (playIcon) playIcon.classList.toggle('is-hidden', playing); if (pauseIcon) pauseIcon.classList.toggle('is-hidden', !playing); if (payload.index >= 0 && state.currentQueue && state.currentQueue[payload.index]) { state.currentTrackIndex = payload.index; if (typeof renderQueue === 'function') renderQueue(); } const duration = Number(payload.duration || 0); const position = Number(payload.position || 0); const progress = duration > 0 ? (position / duration) * 100 : 0; const bar = document.getElementById('progressBar'); if (bar) bar.value = String(Math.max(0, Math.min(100, progress))); const currentLabel = document.getElementById('currentTimeLabel'); const durationLabel = document.getElementById('durationLabel'); if (currentLabel) currentLabel.textContent = formatNativeTime(position); if (durationLabel) durationLabel.textContent = formatNativeTime(duration); if (payload.name) { const title = document.getElementById('dockTitle'); const dockPath = document.getElementById('dockPath'); const nowTitle = document.getElementById('nowTitle'); const nowMeta = document.getElementById('nowMeta'); if (title) title.textContent = payload.name; if (dockPath) dockPath.textContent = payload.path || ''; if (nowTitle) nowTitle.textContent = payload.name; if (nowMeta) nowMeta.textContent = payload.path || ''; } }; window.startQueue = function(tracks, index) { if (!tracks || !tracks.length) return; state.currentQueue = [...tracks]; updateNativeUi(index); AndroidPlayer.playQueue(toQueueJson(), Number(state.currentTrackIndex || 0)); }; window.playTrack = function(index) { if (!state.currentQueue.length) return; updateNativeUi(index); AndroidPlayer.playQueue(toQueueJson(), Number(state.currentTrackIndex || 0)); }; window.playPrevious = function() { AndroidPlayer.previous(); }; window.playNext = function() { AndroidPlayer.next(); }; window.nextTrack = function() { AndroidPlayer.next(); }; const playBtn = document.getElementById('playToggleBtn'); if (playBtn && !playBtn.dataset.nativeBound) { playBtn.dataset.nativeBound = '1'; playBtn.onclick = function() { AndroidPlayer.toggle(); }; } const prevBtn = document.getElementById('prevBtn'); if (prevBtn && !prevBtn.dataset.nativeBound) { prevBtn.dataset.nativeBound = '1'; prevBtn.onclick = function() { AndroidPlayer.previous(); }; } const nextBtn = document.getElementById('nextBtn'); if (nextBtn && !nextBtn.dataset.nativeBound) { nextBtn.dataset.nativeBound = '1'; nextBtn.onclick = function() { AndroidPlayer.next(); }; } sync(); })(); """.trimIndent() webView.evaluateJavascript(script, null) } inner class Bridge { @JavascriptInterface fun setQueue(queueJson: String, index: Int) { val intent = Intent(this@MainActivity, PlayerService::class.java).apply { action = PlayerService.ACTION_SET_QUEUE putExtra(PlayerService.EXTRA_QUEUE_JSON, queueJson) putExtra(PlayerService.EXTRA_INDEX, index) } startService(intent) } @JavascriptInterface fun playQueue(queueJson: String, index: Int) { val intent = Intent(this@MainActivity, PlayerService::class.java).apply { action = PlayerService.ACTION_PLAY_QUEUE putExtra(PlayerService.EXTRA_QUEUE_JSON, queueJson) putExtra(PlayerService.EXTRA_INDEX, index) } ContextCompat.startForegroundService(this@MainActivity, intent) } @JavascriptInterface fun toggle() { startService(Intent(this@MainActivity, PlayerService::class.java).apply { action = PlayerService.ACTION_TOGGLE }) } @JavascriptInterface fun next() { startService(Intent(this@MainActivity, PlayerService::class.java).apply { action = PlayerService.ACTION_NEXT }) } @JavascriptInterface fun previous() { startService(Intent(this@MainActivity, PlayerService::class.java).apply { action = PlayerService.ACTION_PREVIOUS }) } } companion object { private const val KEY_BASE_URL = "base_url" private const val DEFAULT_BASE_URL = "http://110.42.102.94:8006/" } } private fun String.quoteForJs(): String { return buildString { append('"') this@quoteForJs.forEach { char -> when (char) { '\\' -> append("\\\\") '"' -> append("\\\"") '\n' -> append("\\n") '\r' -> append("\\r") '\t' -> append("\\t") else -> append(char) } } append('"') } }