吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 770|回复: 7
上一主题 下一主题
收起左侧

[Java 原创] 飞牛弹幕增强TV/手机版

[复制链接]
跳转到指定楼层
楼主
CoffeeH1 发表于 2026-6-3 14:26 回帖奖励
本帖最后由 CoffeeH1 于 2026-6-4 18:46 编辑

[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
  • 支持勾选"记住密码"实现自动登录

🙏 致谢

  • fntv-electron — 本项目中的飞牛影视 API 接口逻辑参考并解析自该开源项目
  • Danmu API — 弹幕数据服务
  • ExoPlayer — 高性能 Android 播放器

⚠️ 声明

本项目为第三方客户端,与飞牛影视官方无关。使用前请确保遵守相关服务条款。


❓ 常见问题

Q:弹幕无法加载?

  1. 确保已在"设置"中正确配置弹幕服务器地址(默认 http://<NAS>:9321
  2. 检查弹幕服务是否正常运行
  3. 可通过弹幕面板的"弹幕搜索"手动选择剧集

Q:自动匹配弹幕不准确?

  • 自动匹配基于文件名,可能会匹配错误的弹幕数据
  • 匹配失败时可使用搜索面板手动选择,或重新播放再次匹配

Q:遥控器无法操作?

  • 控制栏显示时,方向键在按钮间移动焦点
  • 移到弹幕/锁定按钮,再按一次隐藏控制栏
  • 返回移除焦点,再按一次退出播放(需确认)

Q:播放卡顿?

  • 尝试在设置中切换"解码模式"为硬解或软解
  • 检查网络连接质量

Q:如何更新?

  • 设置页 → 检查更新,支持 CDN 加速下载安装
  • 也可手动下载 APK 安装

📄 License

GNU General Public License v3.0

本项目基于 GPLv3 协议开源。

[/md]

免费评分

参与人数 1威望 +1 吾爱币 +10 热心值 +1 收起 理由
苏紫方璇 + 1 + 10 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
苏紫方璇 发表于 2026-6-3 23:19
请在帖中插入部分关键代码
本版块仅限分享编程技术和源码相关内容,发布帖子必须带上关键代码和具体功能介绍
3#
 楼主| CoffeeH1 发表于 2026-6-4 08:01 |楼主
苏紫方璇 发表于 2026-6-3 23:19
请在帖中插入部分关键代码
本版块仅限分享编程技术和源码相关内容,发布帖子必须带上关键代码和具体功能介 ...

有GIt仓库地址,完整app,代码太多了,贴不出来。

点评

挑选一段贴上去就行  详情 回复 发表于 2026-6-4 16:12
4#
嘿嘿嘿001 发表于 2026-6-4 08:23
5#
natnisa 发表于 2026-6-4 08:46
这个看来起挺牛批的样子,直接赞赞赞
6#
苏紫方璇 发表于 2026-6-4 16:12
CoffeeH1 发表于 2026-6-4 08:01
有GIt仓库地址,完整app,代码太多了,贴不出来。

挑选一段贴上去就行
7#
CESAEREE 发表于 2026-6-8 08:39
大佬牛皮!!!已star  !!!!
8#
 楼主| CoffeeH1 发表于 2026-6-8 09:16 |楼主
CESAEREE 发表于 2026-6-8 08:39
大佬牛皮!!!已star  !!!!

感谢支持。
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-6-18 01:02

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表