commit be5a40bfe3c9cce08f0ef8fcda69751b0b0c9d9f Author: root Date: Sun Sep 27 16:31:00 2026 +0800 Initial hlsmplayer application diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6276901 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.gradle/ +.idea/ +build/ +**/build/ +local.properties +*.iml +*.apk +*.aab +*.keystore +*.jks +*.p12 +keystore.properties +captures/ +externalNativeBuild/ +.cxx/ +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1963199 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# hlsmplayer + +hlsmplayer is an Android TV client for an nginx-hls VOD service. It loads the +media catalog from a configured server, displays server-generated covers, and +plays published HLS playlists in four independently controlled panes. + +## Build + +Use JDK 17 and an Android SDK with platform 36 installed. + +```sh +./gradlew :hlsmplayer:assembleDebug +./gradlew :hlsmplayer:installDebug +``` + +The release variant is minified and uses the Debug signing key for internal +installation, matching the previous project behavior. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..e83f92d --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { id("com.android.application") } + +android { + namespace = "com.hlsmplayer" + compileSdk = 36 + + defaultConfig { + applicationId = "com.hlsmplayer" + minSdk = 23 + targetSdk = 34 + versionCode = 1011001300 + versionName = "1.11.1" + } + + buildTypes { + getByName("release") { + isShrinkResources = true + isMinifyEnabled = true + proguardFiles("proguard-rules.txt", getDefaultProguardFile("proguard-android-optimize.txt")) + signingConfig = signingConfigs.getByName("debug") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation(project(":lib-exoplayer")) + implementation(project(":lib-exoplayer-hls")) + implementation(project(":lib-ui")) + testImplementation(libs.junit) +} diff --git a/app/proguard-rules.txt b/app/proguard-rules.txt new file mode 100644 index 0000000..5ca0811 --- /dev/null +++ b/app/proguard-rules.txt @@ -0,0 +1 @@ +# Proguard rules specific to hlsmplayer. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..51ec1be --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/hlsmplayer/MediaCatalogUrl.java b/app/src/main/java/com/hlsmplayer/MediaCatalogUrl.java new file mode 100644 index 0000000..4c66141 --- /dev/null +++ b/app/src/main/java/com/hlsmplayer/MediaCatalogUrl.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hlsmplayer; + +import java.net.URI; +import java.net.URISyntaxException; + +/** Validates and resolves the relative endpoints published by the VOD catalog. */ +final class MediaCatalogUrl { + + private static final String PLAYLIST_PREFIX = "/hls/"; + private static final String COVER_PREFIX = "/api/media/covers/"; + + private MediaCatalogUrl() {} + + public static boolean isValidPlaylistUrl(String value) { + return isValidRelativePath(value, PLAYLIST_PREFIX) && value.endsWith("/master.m3u8"); + } + + public static boolean isValidCoverUrl(String value) { + return isValidRelativePath(value, COVER_PREFIX) && value.endsWith(".jpg"); + } + + public static String resolve(String serverBase, String relativePath) { + if (serverBase == null || relativePath == null) { + throw new IllegalArgumentException("Missing server base or relative path"); + } + try { + URI base = new URI(serverBase + "/"); + URI endpoint = new URI(relativePath); + return base.resolve(endpoint).toASCIIString(); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid server endpoint", e); + } + } + + private static boolean isValidRelativePath(String value, String requiredPrefix) { + if (value == null || !value.startsWith(requiredPrefix)) { + return false; + } + try { + URI uri = new URI(value); + String rawPath = uri.getRawPath(); + return !uri.isAbsolute() + && uri.getRawAuthority() == null + && uri.getRawQuery() == null + && uri.getRawFragment() == null + && rawPath != null + && rawPath.equals(value) + && !containsDotSegment(rawPath); + } catch (URISyntaxException e) { + return false; + } + } + + private static boolean containsDotSegment(String path) { + for (String segment : path.split("/")) { + if (".".equals(segment) || "..".equals(segment)) { + return true; + } + } + return false; + } +} diff --git a/app/src/main/java/com/hlsmplayer/PlayerActivity.java b/app/src/main/java/com/hlsmplayer/PlayerActivity.java new file mode 100644 index 0000000..261e1b2 --- /dev/null +++ b/app/src/main/java/com/hlsmplayer/PlayerActivity.java @@ -0,0 +1,1278 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hlsmplayer; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.InputType; +import android.util.JsonReader; +import android.util.LruCache; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AlphaAnimation; +import android.widget.AdapterView; +import android.widget.BaseAdapter; +import android.widget.Button; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; +import androidx.media3.common.AudioAttributes; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.Player; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.ui.DefaultTimeBar; +import androidx.media3.ui.PlayerView; +import androidx.media3.ui.TimeBar; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** A four-pane HLS video-on-demand player with a shared media browser. */ +public final class PlayerActivity extends Activity { + + private static final int DEFAULT_SERVER_PORT = 8124; + private static final String PREFERENCES_NAME = "media_player_settings"; + private static final String PREFERENCE_SERVER_CONFIGURED = "server_configured"; + private static final String PREFERENCE_SERVER_HOST = "server_host"; + private static final String PREFERENCE_SERVER_PORT = "server_port"; + private static final long PROGRESS_UPDATE_INTERVAL_MS = 250; + private static final long LONG_PRESS_TIMEOUT_MS = 600; + private static final long SEEK_ACCELERATION_STEP_MS = 3_000; + private static final double SINGLE_SEEK_PERCENT = 0.02; + private static final int COVER_STATE_LOADING = 1; + private static final int COVER_STATE_FAILED = 2; + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final ExecutorService catalogExecutor = Executors.newSingleThreadExecutor(); + private final ExecutorService coverExecutor = Executors.newFixedThreadPool(4); + private final Runnable closeBrowserAction = + () -> { + backLongPressTriggered = true; + closeMediaBrowser(); + }; + private final List browserEntries = new ArrayList<>(); + private final Map coverStates = new HashMap<>(); + private final LruCache coverCache = + new LruCache(getCoverCacheSizeKilobytes()) { + @Override + protected int sizeOf(String key, Bitmap bitmap) { + return Math.max(1, bitmap.getByteCount() / 1024); + } + }; + private final PaneState[] panes = new PaneState[4]; + + private TextView browserPathView; + private TextView browserMessageView; + private FrameLayout browserOverlay; + private ImageView browserCoverPreview; + private ListView browserList; + private Button browserRetryButton; + private BrowserAdapter browserAdapter; + private MediaDirectory mediaRoot; + private PaneState activePane; + private boolean catalogLoaded; + private boolean catalogLoading; + private String catalogError; + private String mediaServerBase; + private int catalogGeneration; + private boolean backLongPressTriggered; + private boolean suppressBrowserCenterKeyUp; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.player_activity); + + readServerSettings(); + browserPathView = findViewById(R.id.browser_path); + browserMessageView = findViewById(R.id.browser_message); + browserOverlay = findViewById(R.id.media_browser_overlay); + browserCoverPreview = findViewById(R.id.browser_cover_preview); + browserRetryButton = findViewById(R.id.browser_retry); + browserList = findViewById(R.id.browser_list); + browserAdapter = new BrowserAdapter(); + + int[] paneIds = { + R.id.player_pane_0, R.id.player_pane_1, R.id.player_pane_2, R.id.player_pane_3 + }; + for (int i = 0; i < paneIds.length; i++) { + panes[i] = new PaneState(i, findViewById(paneIds[i])); + } + setActivePane(panes[0]); + + browserList.setAdapter(browserAdapter); + browserList.setOnItemClickListener((parent, view, position, id) -> activateBrowserEntry(position)); + browserList.setOnItemSelectedListener( + new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + rememberFocusedEntry(position); + updateCoverPreview(); + } + + @Override + public void onNothingSelected(AdapterView parent) { + hideCoverPreview(); + } + }); + browserList.setOnKeyListener((view, keyCode, event) -> handleBrowserKeyEvent(keyCode, event)); + browserRetryButton.setOnClickListener(view -> loadMediaCatalog()); + browserRetryButton.setOnKeyListener( + (view, keyCode, event) -> handleBrowserKeyEvent(keyCode, event)); + + if (mediaServerBase == null) { + showServerSettingsDialog(/* requireConfiguration= */ true); + } else { + loadMediaCatalog(); + } + } + + @Override + public void onStart() { + super.onStart(); + for (PaneState pane : panes) { + if (pane.player != null) { + pane.player.play(); + } + } + } + + @Override + public void onResume() { + super.onResume(); + for (PaneState pane : panes) { + pane.playerView.onResume(); + } + } + + @Override + public void onPause() { + for (PaneState pane : panes) { + pane.playerView.onPause(); + } + super.onPause(); + } + + @Override + public void onStop() { + for (PaneState pane : panes) { + if (pane.player != null) { + pane.player.pause(); + } + } + super.onStop(); + } + + @Override + public void onDestroy() { + mainHandler.removeCallbacksAndMessages(null); + catalogExecutor.shutdownNow(); + coverExecutor.shutdownNow(); + for (PaneState pane : panes) { + releasePlayer(pane); + } + super.onDestroy(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + int keyCode = event.getKeyCode(); + if (keyCode == KeyEvent.KEYCODE_BACK + && event.getAction() == KeyEvent.ACTION_UP + && backLongPressTriggered) { + backLongPressTriggered = false; + return true; + } + if (isBrowserOpen()) { + if (keyCode == KeyEvent.KEYCODE_BACK) { + return handleBrowserKeyEvent(keyCode, event); + } + return super.dispatchKeyEvent(event); + } + if (keyCode == KeyEvent.KEYCODE_MENU) { + if (event.getAction() == KeyEvent.ACTION_UP) { + showServerSettingsDialog(); + } + return true; + } + return handlePlayerKeyEvent(activePane, keyCode, event) || super.dispatchKeyEvent(event); + } + + private void readServerSettings() { + SharedPreferences preferences = getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE); + if (!preferences.getBoolean(PREFERENCE_SERVER_CONFIGURED, false)) { + mediaServerBase = null; + return; + } + String host = preferences.getString(PREFERENCE_SERVER_HOST, ""); + int port = preferences.getInt(PREFERENCE_SERVER_PORT, DEFAULT_SERVER_PORT); + if (!isValidHost(host) || !isValidPort(port)) { + mediaServerBase = null; + return; + } + mediaServerBase = buildServerBase(host, port); + } + + private void showServerSettingsDialog() { + showServerSettingsDialog(/* requireConfiguration= */ false); + } + + private void showServerSettingsDialog(boolean requireConfiguration) { + SharedPreferences preferences = getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE); + String currentHost = preferences.getString(PREFERENCE_SERVER_HOST, ""); + int currentPort = preferences.getInt(PREFERENCE_SERVER_PORT, DEFAULT_SERVER_PORT); + + int horizontalPadding = dpToPixels(24); + LinearLayout form = new LinearLayout(this); + form.setOrientation(LinearLayout.VERTICAL); + form.setPadding(horizontalPadding, 0, horizontalPadding, 0); + EditText hostInput = new EditText(this); + hostInput.setHint(R.string.server_host); + hostInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); + hostInput.setSingleLine(true); + hostInput.setText(currentHost); + EditText portInput = new EditText(this); + portInput.setHint(R.string.server_port); + portInput.setInputType(InputType.TYPE_CLASS_NUMBER); + portInput.setSingleLine(true); + portInput.setText(String.valueOf(currentPort)); + form.addView(hostInput); + form.addView(portInput); + + AlertDialog dialog = + new AlertDialog.Builder(this) + .setTitle(R.string.server_settings) + .setView(form) + .setPositiveButton(R.string.server_save, null) + .create(); + if (!requireConfiguration) { + dialog.setButton(AlertDialog.BUTTON_NEGATIVE, getString(R.string.server_cancel), (dialogInterface, which) -> {}); + } + dialog.setCancelable(!requireConfiguration); + dialog.setCanceledOnTouchOutside(!requireConfiguration); + dialog.setOnShowListener( + unused -> + dialog + .getButton(AlertDialog.BUTTON_POSITIVE) + .setOnClickListener( + view -> { + String host = hostInput.getText().toString().trim(); + int port = parsePort(portInput.getText().toString()); + if (!isValidHost(host) || !isValidPort(port)) { + Toast.makeText(this, R.string.server_invalid, Toast.LENGTH_SHORT).show(); + return; + } + preferences + .edit() + .putBoolean(PREFERENCE_SERVER_CONFIGURED, true) + .putString(PREFERENCE_SERVER_HOST, host) + .putInt(PREFERENCE_SERVER_PORT, port) + .apply(); + mediaServerBase = buildServerBase(host, port); + catalogLoaded = false; + mediaRoot = null; + clearCoverCache(); + dialog.dismiss(); + catalogGeneration++; + catalogLoading = false; + loadMediaCatalog(); + })); + dialog.show(); + } + + private void loadMediaCatalog() { + if (catalogLoading || mediaServerBase == null) { + return; + } + catalogLoading = true; + catalogError = null; + int generation = ++catalogGeneration; + String catalogUrl = mediaServerBase + "/api/media"; + if (isBrowserOpen()) { + showBrowserLoading(); + } + catalogExecutor.execute( + () -> { + try { + MediaDirectory loadedRoot = fetchMediaCatalog(catalogUrl); + mainHandler.post( + () -> { + if (generation == catalogGeneration) { + onMediaCatalogLoaded(loadedRoot, generation); + } + }); + } catch (CatalogResponseException e) { + mainHandler.post( + () -> { + if (generation == catalogGeneration) { + onMediaCatalogFailed(e.statusCode); + } + }); + } catch (IOException e) { + mainHandler.post( + () -> { + if (generation == catalogGeneration) { + onMediaCatalogFailed(/* statusCode= */ -1); + } + }); + } + }); + } + + private static MediaDirectory fetchMediaCatalog(String catalogUrl) throws IOException { + HttpURLConnection connection = (HttpURLConnection) new URL(catalogUrl).openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(5_000); + connection.setReadTimeout(10_000); + connection.setUseCaches(false); + try { + int responseCode = connection.getResponseCode(); + if (responseCode != HttpURLConnection.HTTP_OK) { + throw new CatalogResponseException(responseCode); + } + MediaDirectory root = new MediaDirectory(/* name= */ "", /* parent= */ null); + try (JsonReader reader = + new JsonReader( + new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { + readMediaCatalog(reader, root); + } + return root; + } finally { + connection.disconnect(); + } + } + + private static void readMediaCatalog(JsonReader reader, MediaDirectory root) throws IOException { + reader.beginArray(); + while (reader.hasNext()) { + readMediaItem(reader, root); + } + reader.endArray(); + } + + private static void readMediaItem(JsonReader reader, MediaDirectory root) throws IOException { + String path = null; + String name = null; + String playlistUrl = null; + String coverUrl = null; + reader.beginObject(); + while (reader.hasNext()) { + String fieldName = reader.nextName(); + if (reader.peek() == android.util.JsonToken.NULL) { + reader.nextNull(); + } else if ("path".equals(fieldName)) { + path = reader.nextString(); + } else if ("name".equals(fieldName)) { + name = reader.nextString(); + } else if ("playlistUrl".equals(fieldName)) { + playlistUrl = reader.nextString(); + } else if ("coverUrl".equals(fieldName)) { + coverUrl = reader.nextString(); + } else { + reader.skipValue(); + } + } + reader.endObject(); + if (!isValidMediaItem(path, name, playlistUrl)) { + return; + } + MediaDirectory directory = root; + String[] pathSegments = path.split("/"); + for (int i = 0; i < pathSegments.length - 1; i++) { + directory = directory.getOrCreateChild(pathSegments[i]); + } + directory.files.add( + new MediaFile( + name, + playlistUrl, + MediaCatalogUrl.isValidCoverUrl(coverUrl) ? coverUrl : null, + directory)); + } + + private static boolean isValidMediaItem(String path, String name, String playlistUrl) { + if (path == null || path.isEmpty() || name == null || name.isEmpty() || playlistUrl == null) { + return false; + } + String[] pathSegments = path.split("/"); + for (String segment : pathSegments) { + if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) { + return false; + } + } + return MediaCatalogUrl.isValidPlaylistUrl(playlistUrl); + } + + private void onMediaCatalogLoaded(MediaDirectory root, int generation) { + catalogLoading = false; + if (root.children.isEmpty() && root.files.isEmpty()) { + catalogLoaded = false; + catalogError = getString(R.string.media_empty); + if (isBrowserOpen()) { + showBrowserError(); + } + return; + } + mediaRoot = root; + catalogLoaded = true; + coverStates.clear(); + for (PaneState pane : panes) { + String directoryPath = pane.currentDirectory == null ? "/" : pane.currentDirectory.getDisplayPath(); + String playlistUrl = pane.currentMedia == null ? null : pane.currentMedia.playlistUrl; + pane.currentDirectory = mediaRoot.findDirectory(directoryPath); + if (pane.currentDirectory == null) { + pane.currentDirectory = mediaRoot; + } + pane.currentMedia = playlistUrl == null ? null : mediaRoot.findFileByPlaylistUrl(playlistUrl); + } + if (isBrowserOpen()) { + showMediaDirectory(); + } + } + + private void onMediaCatalogFailed(int statusCode) { + catalogLoading = false; + catalogError = + statusCode == HttpURLConnection.HTTP_UNAVAILABLE + ? getString(R.string.media_building) + : getString(R.string.media_load_error); + if (isBrowserOpen()) { + showBrowserError(); + } + } + + private void requestCover(MediaFile file, int generation) { + String coverUrl = resolveCoverUrl(file); + if (coverUrl != null) { + requestCoverUrl(coverUrl, generation); + } + } + + private void requestCoverUrl(String coverUrl, int generation) { + if (coverCache.get(coverUrl) != null) { + return; + } + Integer state = coverStates.get(coverUrl); + if (state != null) { + return; + } + coverStates.put(coverUrl, COVER_STATE_LOADING); + coverExecutor.execute( + () -> { + Bitmap cover = downloadCover(coverUrl); + mainHandler.post( + () -> { + if (generation != catalogGeneration) { + return; + } + if (cover != null) { + coverCache.put(coverUrl, cover); + coverStates.remove(coverUrl); + browserAdapter.notifyDataSetChanged(); + updateCoverPreview(); + } else { + coverStates.put(coverUrl, COVER_STATE_FAILED); + } + }); + }); + } + + private static Bitmap downloadCover(String coverUrl) { + HttpURLConnection connection = null; + try { + connection = (HttpURLConnection) new URL(coverUrl).openConnection(); + connection.setConnectTimeout(5_000); + connection.setReadTimeout(10_000); + connection.setUseCaches(false); + if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { + return null; + } + try (InputStream inputStream = connection.getInputStream()) { + return BitmapFactory.decodeStream(inputStream); + } + } catch (IOException e) { + return null; + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private void clearCoverCache() { + coverCache.evictAll(); + coverStates.clear(); + } + + private String resolveCoverUrl(MediaFile file) { + return file.coverUrl == null ? null : MediaCatalogUrl.resolve(mediaServerBase, file.coverUrl); + } + + private void openMediaBrowser(PaneState pane) { + activePane = pane; + browserOverlay.setVisibility(View.VISIBLE); + if (catalogLoading) { + showBrowserLoading(); + } else if (!catalogLoaded) { + showBrowserError(); + } else { + showMediaDirectory(); + } + } + + private void closeMediaBrowser() { + mainHandler.removeCallbacks(closeBrowserAction); + for (PaneState pane : panes) { + scheduleProgressUpdate(pane); + } + browserOverlay.setVisibility(View.GONE); + hideCoverPreview(); + } + + private boolean isBrowserOpen() { + return browserOverlay.getVisibility() == View.VISIBLE; + } + + private void showBrowserLoading() { + hideCoverPreview(); + browserPathView.setText(R.string.media_browser_title); + browserList.setVisibility(View.GONE); + browserRetryButton.setVisibility(View.GONE); + browserMessageView.setText(R.string.media_loading); + browserMessageView.setVisibility(View.VISIBLE); + } + + private void showBrowserError() { + hideCoverPreview(); + browserPathView.setText(R.string.media_browser_title); + browserList.setVisibility(View.GONE); + browserMessageView.setText(catalogError == null ? getString(R.string.media_loading) : catalogError); + browserMessageView.setVisibility(View.VISIBLE); + browserRetryButton.setVisibility(View.VISIBLE); + browserRetryButton.requestFocus(); + } + + private void showMediaDirectory() { + if (mediaRoot == null || activePane == null) { + showBrowserError(); + return; + } + if (activePane.currentDirectory == null) { + activePane.currentDirectory = mediaRoot; + } + browserPathView.setText(activePane.currentDirectory.getDisplayPath()); + browserMessageView.setVisibility(View.GONE); + browserRetryButton.setVisibility(View.GONE); + browserList.setVisibility(View.VISIBLE); + browserEntries.clear(); + if (activePane.currentDirectory.parent != null) { + browserEntries.add(BrowserEntry.parent(activePane.currentDirectory.parent)); + } + for (MediaDirectory directory : activePane.currentDirectory.getSortedChildren()) { + browserEntries.add(BrowserEntry.directory(directory)); + } + for (MediaFile file : activePane.currentDirectory.getSortedFiles()) { + browserEntries.add(BrowserEntry.file(file)); + } + browserAdapter.notifyDataSetChanged(); + int selection = findSavedSelection(); + browserList.setSelection(selection); + browserList.post( + () -> { + if (isBrowserOpen()) { + browserList.requestFocus(); + if (!browserEntries.isEmpty()) { + browserList.setItemChecked(selection, true); + updateCoverPreview(); + } + } + }); + } + + private int findSavedSelection() { + if (activePane == null || activePane.currentDirectory == null) { + return browserEntries.isEmpty() ? ListView.INVALID_POSITION : 0; + } + String key = activePane.focusedEntryKeys.get(activePane.currentDirectory.getDisplayPath()); + if (key != null) { + for (int i = 0; i < browserEntries.size(); i++) { + if (key.equals(browserEntries.get(i).key)) { + return i; + } + } + } + return browserEntries.isEmpty() ? ListView.INVALID_POSITION : 0; + } + + private void rememberFocusedEntry(int position) { + if (activePane != null + && activePane.currentDirectory != null + && position >= 0 + && position < browserEntries.size()) { + activePane.focusedEntryKeys.put( + activePane.currentDirectory.getDisplayPath(), browserEntries.get(position).key); + } + } + + private boolean handleBrowserKeyEvent(int keyCode, KeyEvent event) { + if (isCenterKey(keyCode)) { + if (event.getAction() == KeyEvent.ACTION_UP) { + if (suppressBrowserCenterKeyUp) { + suppressBrowserCenterKeyUp = false; + return true; + } + activateBrowserEntry(browserList.getSelectedItemPosition()); + } + return true; + } + if (keyCode == KeyEvent.KEYCODE_BACK) { + if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) { + backLongPressTriggered = false; + mainHandler.postDelayed(closeBrowserAction, LONG_PRESS_TIMEOUT_MS); + } else if (event.getAction() == KeyEvent.ACTION_UP) { + mainHandler.removeCallbacks(closeBrowserAction); + if (!backLongPressTriggered) { + navigateToParentOrClose(); + } + } + return true; + } + if (event.getAction() != KeyEvent.ACTION_DOWN) { + return keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT; + } + if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT && event.getRepeatCount() == 0) { + navigateToParentOrClose(); + return true; + } + if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT && event.getRepeatCount() == 0) { + BrowserEntry entry = getSelectedBrowserEntry(); + if (entry != null && entry.type == BrowserEntry.TYPE_DIRECTORY) { + activePane.currentDirectory = entry.directory; + showMediaDirectory(); + } + return true; + } + return false; + } + + private void activateBrowserEntry(int position) { + if (activePane == null || position < 0 || position >= browserEntries.size()) { + return; + } + BrowserEntry entry = browserEntries.get(position); + rememberFocusedEntry(position); + if (entry.type == BrowserEntry.TYPE_PARENT || entry.type == BrowserEntry.TYPE_DIRECTORY) { + activePane.currentDirectory = entry.directory; + showMediaDirectory(); + return; + } + activePane.currentDirectory = entry.file.parent; + activePane.currentMedia = entry.file; + activePane.focusedEntryKeys.put(activePane.currentDirectory.getDisplayPath(), entry.key); + PaneState targetPane = activePane; + closeMediaBrowser(); + playMedia(targetPane, entry.file); + } + + private BrowserEntry getSelectedBrowserEntry() { + int position = browserList.getSelectedItemPosition(); + return position >= 0 && position < browserEntries.size() ? browserEntries.get(position) : null; + } + + private void navigateToParentOrClose() { + if (activePane != null && activePane.currentDirectory != null && activePane.currentDirectory.parent != null) { + activePane.currentDirectory = activePane.currentDirectory.parent; + showMediaDirectory(); + } else { + closeMediaBrowser(); + } + } + + private boolean handlePlayerKeyEvent(PaneState pane, int keyCode, KeyEvent event) { + if (pane == null) { + return false; + } + if (isCenterKey(keyCode)) { + if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) { + pane.centerLongPressTriggered = false; + mainHandler.postDelayed(pane.openBrowserAction, LONG_PRESS_TIMEOUT_MS); + } else if (event.getAction() == KeyEvent.ACTION_UP) { + mainHandler.removeCallbacks(pane.openBrowserAction); + if (!pane.centerLongPressTriggered) { + cycleActivePane(); + } + } + return true; + } + if (event.getAction() != KeyEvent.ACTION_DOWN) { + return keyCode == KeyEvent.KEYCODE_DPAD_LEFT + || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT + || keyCode == KeyEvent.KEYCODE_DPAD_UP + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN; + } + if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) { + seekByPercent(pane, -1, event.getRepeatCount(), event.getEventTime() - event.getDownTime()); + return true; + } + if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) { + seekByPercent(pane, 1, event.getRepeatCount(), event.getEventTime() - event.getDownTime()); + return true; + } + if (keyCode == KeyEvent.KEYCODE_DPAD_UP && event.getRepeatCount() == 0) { + switchVideoInCurrentDirectory(pane, -1); + return true; + } + if (keyCode == KeyEvent.KEYCODE_DPAD_DOWN && event.getRepeatCount() == 0) { + switchVideoInCurrentDirectory(pane, 1); + return true; + } + return false; + } + + private static boolean isCenterKey(int keyCode) { + return keyCode == KeyEvent.KEYCODE_DPAD_CENTER + || keyCode == KeyEvent.KEYCODE_ENTER + || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER; + } + + private void cycleActivePane() { + setActivePane(panes[(activePane.index + 1) % panes.length]); + } + + private void setActivePane(PaneState pane) { + activePane = pane; + for (PaneState candidate : panes) { + if (candidate != null) { + candidate.focusIndicator.setVisibility(candidate == pane ? View.VISIBLE : View.GONE); + } + } + } + + private void switchVideoInCurrentDirectory(PaneState pane, int direction) { + if (pane.currentDirectory == null || pane.currentMedia == null) { + return; + } + List files = pane.currentDirectory.getSortedFiles(); + int currentIndex = files.indexOf(pane.currentMedia); + if (currentIndex < 0 || files.size() < 2) { + return; + } + MediaFile next = files.get((currentIndex + direction + files.size()) % files.size()); + pane.currentMedia = next; + pane.focusedEntryKeys.put(pane.currentDirectory.getDisplayPath(), BrowserEntry.file(next).key); + playMedia(pane, next); + } + + private void playMedia(PaneState pane, MediaFile file) { + releasePlayer(pane); + ExoPlayer newPlayer = new ExoPlayer.Builder(this).setWakeMode(C.WAKE_MODE_NONE).build(); + pane.player = newPlayer; + newPlayer.setAudioAttributes(AudioAttributes.DEFAULT, /* handleAudioFocus= */ false); + newPlayer.addListener(new PlaybackListener(pane, newPlayer)); + pane.playerView.setPlayer(newPlayer); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri(MediaCatalogUrl.resolve(mediaServerBase, file.playlistUrl)) + .setMimeType(MimeTypes.APPLICATION_M3U8) + .build(); + newPlayer.setMediaItem(mediaItem); + newPlayer.prepare(); + showPaneTitle(pane, file.name); + scheduleProgressUpdate(pane); + } + + private void releasePlayer(PaneState pane) { + mainHandler.removeCallbacks(pane.progressUpdateAction); + if (pane.player != null) { + ExoPlayer playerToRelease = pane.player; + pane.player = null; + playerToRelease.release(); + } + pane.playerView.setPlayer(null); + pane.timeBar.setDuration(C.TIME_UNSET); + pane.timeBar.setPosition(0); + pane.timeBar.setBufferedPosition(0); + pane.scrubbing = false; + } + + private void showPaneTitle(PaneState pane, String title) { + mainHandler.removeCallbacks(pane.titleFadeAction); + pane.titleView.clearAnimation(); + pane.titleView.setText(title); + pane.titleView.setAlpha(1f); + pane.titleView.setVisibility(View.VISIBLE); + mainHandler.postDelayed(pane.titleFadeAction, 1_000); + } + + private void startPaneTitleFade(PaneState pane) { + AlphaAnimation fade = new AlphaAnimation(1f, 0f); + fade.setDuration(1_000); + fade.setFillAfter(true); + fade.setAnimationListener( + new android.view.animation.Animation.AnimationListener() { + @Override + public void onAnimationStart(android.view.animation.Animation animation) {} + + @Override + public void onAnimationEnd(android.view.animation.Animation animation) { + pane.titleView.setVisibility(View.GONE); + pane.titleView.setAlpha(1f); + } + + @Override + public void onAnimationRepeat(android.view.animation.Animation animation) {} + }); + pane.titleView.startAnimation(fade); + } + + private void seekByPercent(PaneState pane, int direction, int repeatCount, long heldDurationMs) { + if (pane.player == null) { + return; + } + long duration = pane.player.getDuration(); + if (duration == C.TIME_UNSET || duration <= 0) { + return; + } + long amount = Math.round(duration * getSeekPercent(repeatCount, heldDurationMs)); + long target = + Math.max(0, Math.min(duration, pane.player.getCurrentPosition() + direction * amount)); + pane.player.seekTo(target); + updateProgress(pane); + } + + private static double getSeekPercent(int repeatCount, long heldDurationMs) { + if (repeatCount == 0) { + return SINGLE_SEEK_PERCENT; + } + if (heldDurationMs < SEEK_ACCELERATION_STEP_MS) { + return 0.04; + } + if (heldDurationMs < 2 * SEEK_ACCELERATION_STEP_MS) { + return 0.06; + } + return 0.10; + } + + private void scheduleProgressUpdate(PaneState pane) { + mainHandler.removeCallbacks(pane.progressUpdateAction); + if (pane.player != null) { + mainHandler.post(pane.progressUpdateAction); + } + } + + private void updateProgress(PaneState pane) { + if (pane.player == null) { + return; + } + if (!pane.scrubbing) { + pane.timeBar.setDuration(pane.player.getDuration()); + pane.timeBar.setPosition(pane.player.getCurrentPosition()); + pane.timeBar.setBufferedPosition(pane.player.getBufferedPosition()); + } + mainHandler.removeCallbacks(pane.progressUpdateAction); + mainHandler.postDelayed(pane.progressUpdateAction, PROGRESS_UPDATE_INTERVAL_MS); + } + + private void updateCoverPreview() { + if (!isBrowserOpen()) { + return; + } + BrowserEntry entry = getSelectedBrowserEntry(); + if (entry == null || entry.file == null) { + hideCoverPreview(); + return; + } + String coverUrl = resolveCoverUrl(entry.file); + if (coverUrl == null) { + hideCoverPreview(); + return; + } + Bitmap cover = coverCache.get(coverUrl); + if (cover == null) { + requestCover(entry.file, catalogGeneration); + hideCoverPreview(); + return; + } + browserOverlay.post( + () -> { + BrowserEntry selectedEntry = getSelectedBrowserEntry(); + if (!isBrowserOpen() + || selectedEntry == null + || selectedEntry.file == null + || !coverUrl.equals(resolveCoverUrl(selectedEntry.file))) { + return; + } + int width = browserOverlay.getWidth() / 2; + FrameLayout.LayoutParams params = + new FrameLayout.LayoutParams(width, Math.max(1, width * 9 / 16), Gravity.TOP | Gravity.END); + browserCoverPreview.setLayoutParams(params); + browserCoverPreview.setImageBitmap(cover); + browserCoverPreview.setVisibility(View.VISIBLE); + }); + } + + private void hideCoverPreview() { + browserCoverPreview.setVisibility(View.GONE); + browserCoverPreview.setImageDrawable(null); + } + + private static int getCoverCacheSizeKilobytes() { + long maxKilobytes = Runtime.getRuntime().maxMemory() / 1024; + return (int) Math.max(8 * 1024, Math.min(64 * 1024, maxKilobytes / 16)); + } + + private int dpToPixels(int dp) { + return Math.round(dp * getResources().getDisplayMetrics().density); + } + + private static int parsePort(String value) { + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return -1; + } + } + + private static boolean isValidHost(String host) { + return host != null + && !host.isEmpty() + && !host.contains("://") + && !host.contains("/") + && !host.contains(" "); + } + + private static boolean isValidPort(int port) { + return port >= 1 && port <= 65_535; + } + + private static String buildServerBase(String host, int port) { + return "http://" + host + ":" + port; + } + + private final class PlaybackListener implements Player.Listener { + + private final PaneState pane; + private final ExoPlayer owner; + + public PlaybackListener(PaneState pane, ExoPlayer owner) { + this.pane = pane; + this.owner = owner; + } + + @Override + public void onPlaybackStateChanged(@Player.State int playbackState) { + if (pane.player == owner && playbackState == Player.STATE_READY) { + owner.play(); + } + } + } + + private final class PaneTimeBarListener implements TimeBar.OnScrubListener { + + private final PaneState pane; + + public PaneTimeBarListener(PaneState pane) { + this.pane = pane; + } + + @Override + public void onScrubStart(TimeBar timeBar, long position) { + pane.scrubbing = true; + } + + @Override + public void onScrubMove(TimeBar timeBar, long position) {} + + @Override + public void onScrubStop(TimeBar timeBar, long position, boolean canceled) { + pane.scrubbing = false; + if (!canceled && pane.player != null) { + pane.player.seekTo(position); + } + updateProgress(pane); + } + } + + private final class BrowserAdapter extends BaseAdapter { + + @Override + public int getCount() { + return browserEntries.size(); + } + + @Override + public BrowserEntry getItem(int position) { + return browserEntries.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + BrowserItemHolder holder; + if (convertView == null) { + convertView = getLayoutInflater().inflate(R.layout.media_browser_item, parent, false); + holder = + new BrowserItemHolder( + convertView.findViewById(R.id.browser_item_cover), + convertView.findViewById(R.id.browser_item_name)); + convertView.setTag(holder); + } else { + holder = (BrowserItemHolder) convertView.getTag(); + } + BrowserEntry entry = getItem(position); + holder.nameView.setText(entry.label); + if (entry.file == null) { + holder.coverView.setImageResource(R.drawable.media_cover_placeholder); + } else { + String coverUrl = resolveCoverUrl(entry.file); + if (coverUrl == null) { + holder.coverView.setImageResource(R.drawable.media_cover_placeholder); + return convertView; + } + Bitmap cover = coverCache.get(coverUrl); + if (cover == null) { + holder.coverView.setImageResource(R.drawable.media_cover_placeholder); + requestCover(entry.file, catalogGeneration); + } else { + holder.coverView.setImageBitmap(cover); + } + } + return convertView; + } + } + + private final class PaneState { + + private final int index; + private final PlayerView playerView; + private final DefaultTimeBar timeBar; + private final View focusIndicator; + private final TextView titleView; + private final Map focusedEntryKeys = new HashMap<>(); + private final Runnable progressUpdateAction = () -> updateProgress(this); + private final Runnable titleFadeAction = () -> startPaneTitleFade(this); + private final Runnable openBrowserAction = + () -> { + centerLongPressTriggered = true; + suppressBrowserCenterKeyUp = true; + openMediaBrowser(this); + }; + private ExoPlayer player; + private MediaDirectory currentDirectory; + private MediaFile currentMedia; + private boolean scrubbing; + private boolean centerLongPressTriggered; + + public PaneState(int index, View root) { + this.index = index; + playerView = root.findViewById(R.id.pane_player_view); + timeBar = root.findViewById(R.id.pane_time_bar); + focusIndicator = root.findViewById(R.id.pane_focus_indicator); + titleView = root.findViewById(R.id.pane_title); + timeBar.addListener(new PaneTimeBarListener(this)); + } + } + + private static final class BrowserItemHolder { + + private final ImageView coverView; + private final TextView nameView; + + public BrowserItemHolder(ImageView coverView, TextView nameView) { + this.coverView = coverView; + this.nameView = nameView; + } + } + + private static final class MediaDirectory { + + private final String name; + private final MediaDirectory parent; + private final Map children = new HashMap<>(); + private final List files = new ArrayList<>(); + + public MediaDirectory(String name, MediaDirectory parent) { + this.name = name; + this.parent = parent; + } + + public MediaDirectory getOrCreateChild(String childName) { + MediaDirectory child = children.get(childName); + if (child == null) { + child = new MediaDirectory(childName, this); + children.put(childName, child); + } + return child; + } + + public List getSortedChildren() { + List sorted = new ArrayList<>(children.values()); + sorted.sort(Comparator.comparing(directory -> directory.name, String.CASE_INSENSITIVE_ORDER)); + return sorted; + } + + public List getSortedFiles() { + List sorted = new ArrayList<>(files); + sorted.sort(Comparator.comparing(file -> file.name, String.CASE_INSENSITIVE_ORDER)); + return sorted; + } + + public String getDisplayPath() { + if (parent == null) { + return "/"; + } + List segments = new ArrayList<>(); + MediaDirectory cursor = this; + while (cursor.parent != null) { + segments.add(cursor.name); + cursor = cursor.parent; + } + Collections.reverse(segments); + StringBuilder path = new StringBuilder(); + for (String segment : segments) { + path.append('/').append(segment); + } + return path.toString(); + } + + public MediaDirectory findDirectory(String path) { + if ("/".equals(path)) { + return this; + } + MediaDirectory directory = this; + String[] segments = path.split("/"); + for (String segment : segments) { + if (segment.isEmpty()) { + continue; + } + directory = directory.children.get(segment); + if (directory == null) { + return null; + } + } + return directory; + } + + public MediaFile findFileByPlaylistUrl(String playlistUrl) { + for (MediaFile file : files) { + if (playlistUrl.equals(file.playlistUrl)) { + return file; + } + } + for (MediaDirectory child : children.values()) { + MediaFile file = child.findFileByPlaylistUrl(playlistUrl); + if (file != null) { + return file; + } + } + return null; + } + + } + + private static final class MediaFile { + + private final String name; + private final String playlistUrl; + private final String coverUrl; + private final MediaDirectory parent; + + public MediaFile(String name, String playlistUrl, String coverUrl, MediaDirectory parent) { + this.name = name; + this.playlistUrl = playlistUrl; + this.coverUrl = coverUrl; + this.parent = parent; + } + } + + private static final class CatalogResponseException extends IOException { + + private final int statusCode; + + public CatalogResponseException(int statusCode) { + super("Unexpected catalog response: " + statusCode); + this.statusCode = statusCode; + } + } + + private static final class BrowserEntry { + + private static final int TYPE_PARENT = 0; + private static final int TYPE_DIRECTORY = 1; + private static final int TYPE_FILE = 2; + + private final int type; + private final MediaDirectory directory; + private final MediaFile file; + private final String key; + private final String label; + + private BrowserEntry(int type, MediaDirectory directory, MediaFile file, String key, String label) { + this.type = type; + this.directory = directory; + this.file = file; + this.key = key; + this.label = label; + } + + public static BrowserEntry parent(MediaDirectory directory) { + return new BrowserEntry(TYPE_PARENT, directory, null, "parent", ".."); + } + + public static BrowserEntry directory(MediaDirectory directory) { + return new BrowserEntry( + TYPE_DIRECTORY, + directory, + null, + "directory:" + directory.getDisplayPath(), + directory.name + "/"); + } + + public static BrowserEntry file(MediaFile file) { + return new BrowserEntry( + TYPE_FILE, null, file, "file:" + file.playlistUrl, file.name); + } + } +} diff --git a/app/src/main/res/drawable/media_browser_item_selector.xml b/app/src/main/res/drawable/media_browser_item_selector.xml new file mode 100644 index 0000000..95ca244 --- /dev/null +++ b/app/src/main/res/drawable/media_browser_item_selector.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/media_cover_placeholder.xml b/app/src/main/res/drawable/media_cover_placeholder.xml new file mode 100644 index 0000000..cb03be0 --- /dev/null +++ b/app/src/main/res/drawable/media_cover_placeholder.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/player_focus_indicator.xml b/app/src/main/res/drawable/player_focus_indicator.xml new file mode 100644 index 0000000..81069cf --- /dev/null +++ b/app/src/main/res/drawable/player_focus_indicator.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/tv_banner.xml b/app/src/main/res/drawable/tv_banner.xml new file mode 100644 index 0000000..ee28539 --- /dev/null +++ b/app/src/main/res/drawable/tv_banner.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/app/src/main/res/layout/media_browser_item.xml b/app/src/main/res/layout/media_browser_item.xml new file mode 100644 index 0000000..250baaf --- /dev/null +++ b/app/src/main/res/layout/media_browser_item.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/app/src/main/res/layout/player_activity.xml b/app/src/main/res/layout/player_activity.xml new file mode 100644 index 0000000..4733bcd --- /dev/null +++ b/app/src/main/res/layout/player_activity.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +