|
@@ -0,0 +1,314 @@
|
|
|
|
|
+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('"')
|
|
|
|
|
+ }
|
|
|
|
|
+}
|