[md]# 飞牛TV 弹幕版 🎯
基于飞牛影视 API 的第三方 Android TV 客户端,支持弹幕、自动连播、TV 遥控器优化操作。
浏览器插件版:https://www.52pojie.cn/thread-2108750-1-1.html
浏览器Docker版:https://www.52pojie.cn/thread-2110030-1-1.html
package com.fntv.app;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Typeface;
import android.view.Choreographer;
import android.view.View;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** 弹幕渲染层 — 自适应刷新率 */
public class DanmuView extends View {
private final Paint paint;
private final List<DanmuItem> items = new ArrayList<>();
private float screenDensity;
private boolean running = false;
private long lastFrame;
private volatile float playTime = 0;
private int maxActive = 40;
private float speedMul = 1f;
private float opacity = 0.85f;
private int areaPct = 35;
private float fontSize = 22f;
private boolean showOutline = true;
private int densityPct = 100;
private float rowSpacing = 1.8f; // 行间距倍数 1.2 ~ 3.0
public DanmuView(Context c) { this(c, null); }
public DanmuView(Context c, android.util.AttributeSet a) { this(c, a, 0); }
public DanmuView(Context c, android.util.AttributeSet a, int defStyle) {
super(c, a, defStyle);
setLayerType(View.LAYER_TYPE_HARDWARE, null);
screenDensity = c.getResources().getDisplayMetrics().density;
paint = new Paint(Paint.ANTI_ALIAS_FLAG);
updateStyle();
}
public void setMaxActive(int v) { maxActive = v; }
public void setSpeedMul(float v) { speedMul = v; }
public void setOpacity(float v) { opacity = v; updateStyle(); }
public void setAreaPct(int v) { areaPct = v; }
public void setFontSize(float v) { fontSize = v; updateStyle(); }
public void setShowOutline(boolean v) { showOutline = v; updateStyle(); }
public void setDensityPct(int v) { densityPct = v; }
public void setRowSpacing(float v) { rowSpacing = v; }
private void updateStyle() {
paint.setTextSize(fontSize * screenDensity);
paint.setAlpha((int)(255 * opacity));
paint.setTypeface(Typeface.DEFAULT_BOLD);
paint.setStyle(Paint.Style.FILL);
}
public void setPlayTime(long ms) { playTime = ms / 1000f; }
public void loadDanmu(List<DanmuComment> comments) {
items.clear();
active.clear();
eIdx = 0;
if (comments == null) return;
for (DanmuComment c : comments) {
DanmuItem item = new DanmuItem();
item.text = c.text;
item.time = c.time;
item.color = c.color != 0 ? c.color : 0xFFFFFFFF;
item.type = c.type;
item.fontSize = c.fontSize > 0 ? c.fontSize : fontSize;
items.add(item);
}
Collections.sort(items, (a, b) -> Float.compare(a.time, b.time));
}
public void start() {
if (running) return;
running = true;
lastFrame = System.nanoTime();
Choreographer.getInstance().postFrameCallback(frameCallback);
}
public void stop() {
running = false;
Choreographer.getInstance().removeFrameCallback(frameCallback);
}
public void pause() {
running = false;
Choreographer.getInstance().removeFrameCallback(frameCallback);
}
/** 跳转时重置弹幕状态*/
public void seekToTime(long ms) {
playTime = ms / 1000f;
active.clear();
// 二分定位到当前时间对应的弹幕索引
int lo = 0, hi = items.size();
while (lo < hi) {
int mid = (lo + hi) >> 1;
if (items.get(mid).time <= playTime) lo = mid + 1;
else hi = mid;
}
eIdx = lo;
}
public void resume() {
if (items.isEmpty()) return;
running = true;
lastFrame = System.nanoTime();
Choreographer.getInstance().postFrameCallback(frameCallback);
}
public void clear() {
items.clear();
active.clear();
eIdx = 0;
}
private static class DanmuItem {
String text;
float time;
int color;
int type;
float fontSize;
float x, y, speed, tw;
}
public static class DanmuComment {
public String text;
public float time;
public int color = 0xFFFFFFFF;
public int type;
public float fontSize;
}
private final List<DanmuItem> active = new ArrayList<>();
private int eIdx = 0;
private final Choreographer.FrameCallback frameCallback = new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
if (!running) return;
long now = System.nanoTime();
float dt = (now - lastFrame) / 1_000_000_000f;
lastFrame = now;
if (dt > 0.1f) dt = 0.016f;
int w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) {
Choreographer.getInstance().postFrameCallback(this);
return;
}
float areaH = h * areaPct / 100f;
float lnH = fontSize * screenDensity * rowSpacing; // 用 rowSpacing 替代写死的 1.8
int maxRow = Math.max(1, (int) (areaH / lnH));
// 发射
while (eIdx < items.size()) {
DanmuItem src = items.get(eIdx);
float diff = playTime - src.time;
if (diff > 0.5f) { eIdx++; continue; }
if (diff < 0) break;
// 发射错开:每条弹幕需要等一个随机 0~0.3s
if (diff < Math.random() * 0.3f) break;
if (Math.random() * 100 >= densityPct) { eIdx++; continue; }
if (active.size() >= maxActive) break;
DanmuItem a = new DanmuItem();
a.text = src.text;
a.color = src.color;
a.type = src.type;
a.tw = paint.measureText(src.text);
// 速度按文字长度变化 + 随机波动
int len = Math.max(1, src.text.length());
float baseSpeed = 120 + len * 18;
float randomFactor = 0.8f + (float)(Math.random() * 0.4);
a.speed = baseSpeed * randomFactor * speedMul;
float rowY = findRow(a.tw, w, lnH, maxRow);
if (rowY < 0) {
eIdx++;
continue;
}
a.y = rowY;
a.x = w + 5;
active.add(a);
eIdx++;
}
// 更新位置
List<DanmuItem> dead = new ArrayList<>();
for (DanmuItem a : active) {
a.x -= a.speed * dt;
if (a.x + a.tw < -100) dead.add(a);
}
active.removeAll(dead);
invalidate();
Choreographer.getInstance().postFrameCallback(this);
}
};
/**
* 行避让:随机间隔(20dp ~ 60dp)
*/
private float findRow(float newTw, int screenW, float lnH, int maxRow) {
float gap = (20f + (float)(Math.random() * 40)) * screenDensity;
for (int r = 0; r < maxRow; r++) {
float rowY = lnH + r * lnH;
boolean blocked = false;
for (DanmuItem a : active) {
if (Math.abs(a.y - rowY) < lnH * 0.5f) {
if (a.x + a.tw + gap > screenW) {
blocked = true;
break;
}
}
}
if (!blocked) return rowY;
}
return -1;
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
paint.setTextSize(fontSize * screenDensity);
for (DanmuItem a : active) {
if (showOutline) {
paint.setColor(Color.BLACK);
paint.setAlpha((int)(255 * opacity));
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(0.8f * screenDensity);
paint.setStrokeCap(Paint.Cap.ROUND);
paint.setStrokeJoin(Paint.Join.ROUND);
c.drawText(a.text, a.x, a.y, paint);
}
paint.setColor(a.color);
paint.setAlpha((int)(255 * opacity));
paint.setStyle(Paint.Style.FILL);
c.drawText(a.text, a.x, a.y, paint);
}
}
}
📥 下载与使用
方式一:直接下载 APK
从 Releases 页面下载最新版本的 APK 安装包,在 Android 电视/手机上安装即可使用。
国内加速链接
方式二:本地构建
# 1. 克隆项目
git clone https://github.com/rgcaafe/fnos_tv_danmu.git
cd fnos_tv_danmu
# 2. 编译(需要 Android SDK)
# Windows (gradlew.bat)
.\gradlew assembleRelease
# Linux / macOS
./gradlew assembleRelease
# 3. 编译完成后 APK 位于:
# app/build/outputs/apk/release/FNTV_release_*.apk
注意:本地构建需要安装 Android Studio 并配置好 Android SDK(API 33)。如果使用 Windows 系统,构建时部分依赖可能需要科学上网。
📦 功能特点
- 弹幕支持 — 集成 Danmu API,自动匹配番剧弹幕,支持搜索手动选择剧集
- 继续观看 — 记录播放进度,首页快速续播
- 剧集自动连播 — 播放完成后自动播放下一集
- TV 遥控器优化 — 全界面 DPAD 焦点导航,适配电视遥控器
- 倍速播放 — 支持 0.5x ~ 2.0x 播放速度
- 播放比例 — 适应 / 拉伸 两种模式
- 硬解/软解切换 — 支持硬件解码与软件解码
- 锁定模式 — 锁定后隐藏控制栏,防止误触
- 自动更新 — 内置更新检测,支持 CDN 加速下载安装
🛠 技术栈
- 开发语言: Java
- 播放器: ExoPlayer 2.18.7
- 网络请求: Retrofit2 + OkHttp3
- 弹幕渲染: 自定义 Canvas 弹幕引擎
- 最低支持: Android 4.4 (API 19)
- 目标 SDK: Android 12 (API 32)
🔐 登录说明
登录账号密码为 飞牛影视的账号密码,非本应用的独立账号。
- 服务器地址格式:
http://<你的NAS地址>:<端口>
- 默认端口通常为
5666
- 支持勾选"记住密码"实现自动登录
🙏 致谢
⚠️ 声明
本项目为第三方客户端,与飞牛影视官方无关。使用前请确保遵守相关服务条款。
❓ 常见问题
Q:弹幕无法加载?
- 确保已在"设置"中正确配置弹幕服务器地址(默认
http://<NAS>:9321)
- 检查弹幕服务是否正常运行
- 可通过弹幕面板的"弹幕搜索"手动选择剧集
Q:自动匹配弹幕不准确?
- 自动匹配基于文件名,可能会匹配错误的弹幕数据
- 匹配失败时可使用搜索面板手动选择,或重新播放再次匹配
Q:遥控器无法操作?
- 控制栏显示时,方向键在按钮间移动焦点
- 按
↑ 移到弹幕/锁定按钮,再按一次隐藏控制栏
- 按
← 返回移除焦点,再按一次退出播放(需确认)
Q:播放卡顿?
- 尝试在设置中切换"解码模式"为硬解或软解
- 检查网络连接质量
Q:如何更新?
- 设置页 → 检查更新,支持 CDN 加速下载安装
- 也可手动下载 APK 安装
📄 License
GNU General Public License v3.0
本项目基于 GPLv3 协议开源。