From 9cf2beef408b3d987446c5e2f18b3d8dff4fd0d8 Mon Sep 17 00:00:00 2001 From: "xiao12feng@outlook.com" Date: Sun, 21 Dec 2025 17:35:06 +0800 Subject: [PATCH] feat(android): add IjkPlayer flv playback with hls fallback --- .../example/livestreaming/MainActivity.java | 14 +- .../example/livestreaming/PlayerActivity.java | 152 +++++++++++++- .../livestreaming/RoomDetailActivity.java | 193 +++++++++++++++--- .../main/res/layout/activity_fish_pond.xml | 2 +- .../src/main/res/layout/activity_player.xml | 10 + .../res/layout/activity_room_detail_new.xml | 6 + .../app/src/main/res/values/themes.xml | 5 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- android-app/settings.gradle.kts | 1 + 9 files changed, 342 insertions(+), 43 deletions(-) diff --git a/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java b/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java index da676ef5..3e39b0a7 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java @@ -470,20 +470,8 @@ public class MainActivity extends AppCompatActivity { String streamKey = room != null ? room.getStreamKey() : null; String rtmp = room != null && room.getStreamUrls() != null ? room.getStreamUrls().getRtmp() : null; - // 将RTMP地址中的10.0.2.2或192.168.x.x替换为localhost供OBS使用 + // 直接使用服务器返回的RTMP地址 String rtmpForObs = rtmp; - if (!TextUtils.isEmpty(rtmp)) { - try { - Uri u = Uri.parse(rtmp); - int port = u.getPort(); - String path = u.getPath(); - if (port > 0 && !TextUtils.isEmpty(path)) { - // OBS推流时使用localhost - rtmpForObs = "rtmp://localhost:" + port + path; - } - } catch (Exception ignored) { - } - } // 创建自定义弹窗布局 View dialogView = getLayoutInflater().inflate(R.layout.dialog_stream_info, null); diff --git a/android-app/app/src/main/java/com/example/livestreaming/PlayerActivity.java b/android-app/app/src/main/java/com/example/livestreaming/PlayerActivity.java index 1ed97574..5bd8c95e 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/PlayerActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/PlayerActivity.java @@ -1,6 +1,8 @@ package com.example.livestreaming; import android.os.Bundle; +import android.view.Surface; +import android.view.TextureView; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; @@ -11,6 +13,9 @@ import androidx.media3.exoplayer.ExoPlayer; import com.example.livestreaming.databinding.ActivityPlayerBinding; +import tv.danmaku.ijk.media.player.IMediaPlayer; +import tv.danmaku.ijk.media.player.IjkMediaPlayer; + public class PlayerActivity extends AppCompatActivity { public static final String EXTRA_PLAY_URL = "extra_play_url"; @@ -20,6 +25,14 @@ public class PlayerActivity extends AppCompatActivity { private ExoPlayer player; private boolean triedAltUrl = false; + private IjkMediaPlayer ijkPlayer; + private Surface ijkSurface; + private String ijkUrl; + private String ijkFallbackHlsUrl; + private boolean ijkFallbackTried; + + private static boolean ijkLibLoaded; + @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -38,6 +51,24 @@ public class PlayerActivity extends AppCompatActivity { triedAltUrl = false; + if (url.endsWith(".flv")) { + startFlv(url, null); + return; + } + + startHls(url, null); + } + + private void startHls(String url, @Nullable String altUrl) { + releaseIjkPlayer(); + if (binding != null) { + binding.flvTextureView.setVisibility(android.view.View.GONE); + binding.playerView.setVisibility(android.view.View.VISIBLE); + } + + releaseExoPlayer(); + triedAltUrl = false; + // 创建低延迟播放器配置 ExoPlayer exo = new ExoPlayer.Builder(this) .setLoadControl(new androidx.media3.exoplayer.DefaultLoadControl.Builder() @@ -58,15 +89,18 @@ public class PlayerActivity extends AppCompatActivity { binding.playerView.setUseController(true); binding.playerView.setControllerAutoShow(false); - String altUrl = getAltHlsUrl(url); + String computedAltUrl = altUrl; + if (computedAltUrl == null || computedAltUrl.trim().isEmpty()) computedAltUrl = getAltHlsUrl(url); + + String finalComputedAltUrl = computedAltUrl; exo.addListener(new Player.Listener() { @Override public void onPlayerError(PlaybackException error) { if (triedAltUrl) return; - if (altUrl == null || altUrl.trim().isEmpty()) return; + if (finalComputedAltUrl == null || finalComputedAltUrl.trim().isEmpty()) return; triedAltUrl = true; - exo.setMediaItem(MediaItem.fromUri(altUrl)); + exo.setMediaItem(MediaItem.fromUri(finalComputedAltUrl)); exo.prepare(); exo.setPlayWhenReady(true); } @@ -81,10 +115,122 @@ public class PlayerActivity extends AppCompatActivity { @Override protected void onStop() { super.onStop(); + releaseExoPlayer(); + releaseIjkPlayer(); + } + + private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) { + ensureIjkLibsLoaded(); + releaseExoPlayer(); + releaseIjkPlayer(); + + ijkUrl = flvUrl; + ijkFallbackHlsUrl = fallbackHlsUrl; + ijkFallbackTried = false; + + if (binding != null) { + binding.playerView.setVisibility(android.view.View.GONE); + binding.flvTextureView.setVisibility(android.view.View.VISIBLE); + } + + TextureView view = binding.flvTextureView; + TextureView.SurfaceTextureListener listener = new TextureView.SurfaceTextureListener() { + @Override + public void onSurfaceTextureAvailable(android.graphics.SurfaceTexture surfaceTexture, int width, int height) { + ijkSurface = new Surface(surfaceTexture); + prepareIjk(flvUrl); + } + + @Override + public void onSurfaceTextureSizeChanged(android.graphics.SurfaceTexture surface, int width, int height) { + } + + @Override + public boolean onSurfaceTextureDestroyed(android.graphics.SurfaceTexture surface) { + releaseIjkPlayer(); + return true; + } + + @Override + public void onSurfaceTextureUpdated(android.graphics.SurfaceTexture surface) { + } + }; + + view.setSurfaceTextureListener(listener); + if (view.isAvailable() && view.getSurfaceTexture() != null) { + ijkSurface = new Surface(view.getSurfaceTexture()); + prepareIjk(flvUrl); + } + } + + private void prepareIjk(String url) { + if (ijkSurface == null) return; + + IjkMediaPlayer p = new IjkMediaPlayer(); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "packet-buffering", 0); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "start-on-prepared", 1); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "fflags", "nobuffer"); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "analyzeduration", 1); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "probesize", 1024); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "framedrop", 1); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "max_cached_duration", 300); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "infbuf", 1); + + p.setOnPreparedListener(mp -> mp.start()); + p.setOnErrorListener((IMediaPlayer mp, int what, int extra) -> { + if (ijkFallbackTried || ijkFallbackHlsUrl == null || ijkFallbackHlsUrl.trim().isEmpty()) return true; + ijkFallbackTried = true; + startHls(ijkFallbackHlsUrl, null); + return true; + }); + + ijkPlayer = p; + try { + p.setSurface(ijkSurface); + p.setDataSource(url); + p.prepareAsync(); + } catch (Exception e) { + if (ijkFallbackHlsUrl != null && !ijkFallbackHlsUrl.trim().isEmpty()) { + startHls(ijkFallbackHlsUrl, null); + } + } + } + + private static void ensureIjkLibsLoaded() { + if (ijkLibLoaded) return; + try { + IjkMediaPlayer.loadLibrariesOnce(null); + IjkMediaPlayer.native_profileBegin("libijkplayer.so"); + } catch (Throwable ignored) { + } + ijkLibLoaded = true; + } + + private void releaseExoPlayer() { if (player != null) { player.release(); player = null; } + if (binding != null) binding.playerView.setPlayer(null); + } + + private void releaseIjkPlayer() { + if (ijkPlayer != null) { + ijkPlayer.reset(); + ijkPlayer.release(); + ijkPlayer = null; + } + if (ijkSurface != null) { + ijkSurface.release(); + ijkSurface = null; + } + ijkUrl = null; + ijkFallbackHlsUrl = null; + ijkFallbackTried = false; + if (binding != null) { + binding.flvTextureView.setVisibility(android.view.View.GONE); + binding.playerView.setVisibility(android.view.View.VISIBLE); + } } private String getAltHlsUrl(String url) { diff --git a/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java b/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java index ee2cdeb3..07d43522 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java @@ -6,6 +6,8 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.text.TextUtils; +import android.view.Surface; +import android.view.TextureView; import android.view.KeyEvent; import android.view.View; import android.view.WindowManager; @@ -26,6 +28,9 @@ import com.example.livestreaming.net.ApiClient; import com.example.livestreaming.net.ApiResponse; import com.example.livestreaming.net.Room; +import tv.danmaku.ijk.media.player.IMediaPlayer; +import tv.danmaku.ijk.media.player.IjkMediaPlayer; + import java.util.ArrayList; import java.util.List; import java.util.Random; @@ -48,6 +53,13 @@ public class RoomDetailActivity extends AppCompatActivity { private ExoPlayer player; private boolean triedAltUrl; + private IjkMediaPlayer ijkPlayer; + private Surface ijkSurface; + private String ijkUrl; + private String ijkFallbackHlsUrl; + private boolean ijkFallbackTried; + + private static boolean ijkLibLoaded; private boolean isFullscreen = false; private ChatAdapter chatAdapter; @@ -358,16 +370,16 @@ public class RoomDetailActivity extends AppCompatActivity { // 获取播放地址 String playUrl = null; + String fallbackHlsUrl = null; if (r.getStreamUrls() != null) { // 优先使用HTTP-FLV,延迟更低 playUrl = r.getStreamUrls().getFlv(); - if (TextUtils.isEmpty(playUrl)) { - playUrl = r.getStreamUrls().getHls(); - } + fallbackHlsUrl = r.getStreamUrls().getHls(); + if (TextUtils.isEmpty(playUrl)) playUrl = fallbackHlsUrl; } if (!TextUtils.isEmpty(playUrl)) { - ensurePlayer(playUrl); + ensurePlayer(playUrl, fallbackHlsUrl); } else { // 没有播放地址时显示离线状态 binding.offlineLayout.setVisibility(View.VISIBLE); @@ -375,7 +387,15 @@ public class RoomDetailActivity extends AppCompatActivity { } } - private void ensurePlayer(String url) { + private void ensurePlayer(String url, String fallbackHlsUrl) { + if (TextUtils.isEmpty(url)) return; + + if (url.endsWith(".flv")) { + if (TextUtils.equals(ijkUrl, url) && ijkPlayer != null) return; + startFlv(url, fallbackHlsUrl); + return; + } + if (player != null) { MediaItem current = player.getCurrentMediaItem(); String currentUri = current != null && current.localConfiguration != null && current.localConfiguration.uri != null @@ -385,36 +405,45 @@ public class RoomDetailActivity extends AppCompatActivity { if (currentUri != null && currentUri.equals(url)) return; } - releasePlayer(); + startHls(url, null); + } + + private void startHls(String url, @Nullable String altUrl) { + releaseIjkPlayer(); + if (binding != null) { + binding.flvTextureView.setVisibility(View.GONE); + binding.playerView.setVisibility(View.VISIBLE); + } + + releaseExoPlayer(); triedAltUrl = false; - // 创建低延迟播放器配置 ExoPlayer exo = new ExoPlayer.Builder(this) .setLoadControl(new androidx.media3.exoplayer.DefaultLoadControl.Builder() - // 减少缓冲区大小,降低延迟 .setBufferDurationsMs( - 1000, // 最小缓冲时长 1秒 - 3000, // 最大缓冲时长 3秒 - 500, // 播放缓冲时长 0.5秒 - 1000 // 播放后缓冲时长 1秒 + 1000, + 3000, + 500, + 1000 ) .build()) .build(); - + binding.playerView.setPlayer(exo); - - // 设置播放器监听器 - String altUrl = getAltHlsUrl(url); + + String computedAltUrl = altUrl; + if (TextUtils.isEmpty(computedAltUrl)) computedAltUrl = getAltHlsUrl(url); + + String finalComputedAltUrl = computedAltUrl; exo.addListener(new Player.Listener() { @Override public void onPlayerError(PlaybackException error) { - if (triedAltUrl || TextUtils.isEmpty(altUrl)) { - // 播放失败,显示离线状态 + if (triedAltUrl || TextUtils.isEmpty(finalComputedAltUrl)) { binding.offlineLayout.setVisibility(View.VISIBLE); return; } triedAltUrl = true; - exo.setMediaItem(MediaItem.fromUri(altUrl)); + exo.setMediaItem(MediaItem.fromUri(finalComputedAltUrl)); exo.prepare(); exo.setPlayWhenReady(true); } @@ -422,10 +451,7 @@ public class RoomDetailActivity extends AppCompatActivity { @Override public void onPlaybackStateChanged(int playbackState) { if (playbackState == Player.STATE_READY) { - // 播放成功,隐藏离线状态 binding.offlineLayout.setVisibility(View.GONE); - - // 添加系统消息 addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true)); } } @@ -437,12 +463,133 @@ public class RoomDetailActivity extends AppCompatActivity { player = exo; } + private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) { + ensureIjkLibsLoaded(); + releaseExoPlayer(); + releaseIjkPlayer(); + + ijkUrl = flvUrl; + ijkFallbackHlsUrl = fallbackHlsUrl; + ijkFallbackTried = false; + + if (binding != null) { + binding.playerView.setVisibility(View.GONE); + binding.flvTextureView.setVisibility(View.VISIBLE); + } + + TextureView view = binding.flvTextureView; + TextureView.SurfaceTextureListener listener = new TextureView.SurfaceTextureListener() { + @Override + public void onSurfaceTextureAvailable(@NonNull android.graphics.SurfaceTexture surfaceTexture, int width, int height) { + ijkSurface = new Surface(surfaceTexture); + prepareIjk(flvUrl); + } + + @Override + public void onSurfaceTextureSizeChanged(@NonNull android.graphics.SurfaceTexture surface, int width, int height) { + } + + @Override + public boolean onSurfaceTextureDestroyed(@NonNull android.graphics.SurfaceTexture surface) { + releaseIjkPlayer(); + return true; + } + + @Override + public void onSurfaceTextureUpdated(@NonNull android.graphics.SurfaceTexture surface) { + } + }; + + view.setSurfaceTextureListener(listener); + if (view.isAvailable() && view.getSurfaceTexture() != null) { + ijkSurface = new Surface(view.getSurfaceTexture()); + prepareIjk(flvUrl); + } + } + + private void prepareIjk(String url) { + if (ijkSurface == null) return; + + IjkMediaPlayer p = new IjkMediaPlayer(); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "packet-buffering", 0); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "start-on-prepared", 1); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "fflags", "nobuffer"); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "analyzeduration", 1); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "probesize", 1024); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "framedrop", 1); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "max_cached_duration", 300); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "infbuf", 1); + + p.setOnPreparedListener(mp -> { + binding.offlineLayout.setVisibility(View.GONE); + mp.start(); + addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true)); + }); + + p.setOnErrorListener((IMediaPlayer mp, int what, int extra) -> { + if (ijkFallbackTried || TextUtils.isEmpty(ijkFallbackHlsUrl)) { + binding.offlineLayout.setVisibility(View.VISIBLE); + return true; + } + ijkFallbackTried = true; + startHls(ijkFallbackHlsUrl, null); + return true; + }); + + ijkPlayer = p; + try { + p.setSurface(ijkSurface); + p.setDataSource(url); + p.prepareAsync(); + } catch (Exception e) { + if (!TextUtils.isEmpty(ijkFallbackHlsUrl)) { + startHls(ijkFallbackHlsUrl, null); + } else { + binding.offlineLayout.setVisibility(View.VISIBLE); + } + } + } + + private static void ensureIjkLibsLoaded() { + if (ijkLibLoaded) return; + try { + IjkMediaPlayer.loadLibrariesOnce(null); + IjkMediaPlayer.native_profileBegin("libijkplayer.so"); + } catch (Throwable ignored) { + } + ijkLibLoaded = true; + } + private void releasePlayer() { + releaseExoPlayer(); + releaseIjkPlayer(); + if (binding != null) { + binding.playerView.setPlayer(null); + binding.flvTextureView.setVisibility(View.GONE); + binding.playerView.setVisibility(View.VISIBLE); + } + } + + private void releaseExoPlayer() { if (player != null) { player.release(); player = null; } - if (binding != null) binding.playerView.setPlayer(null); + } + + private void releaseIjkPlayer() { + if (ijkPlayer != null) { + ijkPlayer.reset(); + ijkPlayer.release(); + ijkPlayer = null; + } + if (ijkSurface != null) { + ijkSurface.release(); + ijkSurface = null; + } + ijkUrl = null; + ijkFallbackHlsUrl = null; + ijkFallbackTried = false; } private String getAltHlsUrl(String url) { diff --git a/android-app/app/src/main/res/layout/activity_fish_pond.xml b/android-app/app/src/main/res/layout/activity_fish_pond.xml index ca931c8e..3c78a46f 100644 --- a/android-app/app/src/main/res/layout/activity_fish_pond.xml +++ b/android-app/app/src/main/res/layout/activity_fish_pond.xml @@ -472,7 +472,7 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/orbitContainer" - app:layout_constraintBottom_toTopOf="@id/bottomAppBar"> + app:layout_constraintBottom_toTopOf="@id/bottomNavInclude"> + + + + 14sp -