feat(android): add IjkPlayer flv playback with hls fallback

This commit is contained in:
xiao12feng@outlook.com 2025-12-21 17:35:06 +08:00
parent 087e1feae6
commit 9cf2beef40
9 changed files with 342 additions and 43 deletions

View File

@ -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);

View File

@ -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) {

View File

@ -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) {

View File

@ -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">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"

View File

@ -4,6 +4,16 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextureView
android:id="@+id/flvTextureView"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.media3.ui.PlayerView
android:id="@+id/playerView"
android:layout_width="0dp"

View File

@ -94,6 +94,12 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/topBar">
<TextureView
android:id="@+id/flvTextureView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<androidx.media3.ui.PlayerView
android:id="@+id/playerView"
android:layout_width="match_parent"

View File

@ -26,9 +26,10 @@
<item name="android:textSize">14sp</item>
</style>
<style name="ShapeAppearanceOverlay" parent="ShapeAppearanceOverlay.MaterialComponents" />
<!-- 基础形状样式 -->
<style name="ShapeAppearanceOverlay" parent="ShapeAppearance.MaterialComponents" />
<style name="ShapeAppearanceOverlay.MaterialComponents" />
<style name="ShapeAppearanceOverlay.MaterialComponents" parent="ShapeAppearance.MaterialComponents" />
<style name="ShapeAppearanceOverlay.MaterialComponents.Circular" parent="ShapeAppearanceOverlay.MaterialComponents">
<item name="cornerSize">50%</item>

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=file:///D:/soft/gradle-8.1-bin.zip
distributionUrl=file:///D:/soft/gradle-8.14-bin.zip
networkTimeout=600000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -11,6 +11,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven(url = "https://jitpack.io")
}
}