好玩的字符画

好玩的字符画播放器 CharPlayer

1. 说明

该项目是将图片及视频以字符画的方式呈现
是抖音比较火爆的特效
基于个人爱好开发分享给爱折腾的小伙伴们

1.1 技术说明

引用库 库说明 引用说明 备注
vue 虚拟DOM框架 加快dom渲染及内部变量间联动控制 核心代码
js-url url解析 url参数解析使用
stats.js 性能监视器 监视运行时(帧率/帧耗时/内存)情况
hls.js 直播流解析 对m3u8直播流的解析支持
flv.js flv解码 对flv文件及直播流的解析支持
dot js模板引擎 利用模板加快数据帧数据生成
libgif GIF解析器和播放器 对gif动画效果的解析支持
inferno 虚拟DOM框架 利用该框架的高性能差异化渲染 供渲染方法五使用
inferno-create-element inferno扩展 方便inferno的节点创建

1.2 github开源地址

https://github.com/Febby315/code_wallpaper/tree/master/TXTplayer/v3

1.3 预览地址

https://g.febby315.top/TXTplayer/v3/index.html

1.2.1 默认效果预览

默认特效.jpg

1.2.2 全特效预览

特效全开.jpg

1.4 使用说明

1.4.1 URL参数说明

参数 默认值 说明 备注
src video/v.mp4 图片/视频uri地址 uri编码后的字符串
showStats 显示(帧率、耗时、内存)性能信息
enableColor 启用颜色 该特效会严重影响性能
enableReverse 反转前景与背景色 目前不美观
className 启用内置的特效 目前仅支持(shadow、reverse)
style 额外的样式 经过JSON.stringify&的对象字符串

注意: src参数不支持跨域资源但支持flv、m3u8直播链接
src和style参数都需要编码为uri参数

1.4.2 示例地址

  // 外部图片源(需要经过uri编码)
  var imgSrc = decodeURIComponent('https://i.loli.net/2019/09/02/yOHcCG7XlFVv4M5.png');
  // 自定义样式(先经过JSON字符序列化再经过uri编码)
  var style = decodeURIComponent(JSON.stringify({ transform: 'scale(-0.8)' }));
  // 阴影&性能信息
  var url = `https://g.febby315.top/TXTplayer/v3/?className=shadow&showStats=1`;
  // 彩色&外链图片:
  var url = `https://g.febby315.top/TXTplayer/v3/?enableColor=1&src=${imgSrc}`;
  // 反转色彩&自定义样式
  var url = `https://g.febby315.top/TXTplayer/v3/?enableReverse=1&style=${style}`;

1.4.3 支持格式

  • 图片: (.jpg|.jpeg|.png|.gif)
  • 视频: (.mp4|.ogg|.webp|.flv)
  • 直播链接: (.flv|.m3u8)

2. 完整代码

2.1. index.html

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>字符画 v3</title>
    <link rel="stylesheet" href="css/style.css">
    <!-- CDN http://www.jsdelivr.com/ -->
    <script src="//cdn.jsdelivr.net/npm/js-url@2.3.0/url.min.js"></script>
    <script src="//cdn.jsdelivr.net/npm/hls.js@0.13.0/dist/hls.light.min.js"></script>
    <script src="//cdn.jsdelivr.net/npm/stats.js@0.17.0/build/stats.min.js"></script>
    <script src="//cdn.jsdelivr.net/npm/flv.js@1.5.0/dist/flv.min.js"></script>
    <script src="//cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.min.js"></script>
    <script src="//cdn.jsdelivr.net/npm/dot@1.1.3/doT.min.js"></script>
    <script src="//cdn.jsdelivr.net/npm/libgif@0.0.3/libgif.min.js"></script>
    <!-- inferno -->
    <script src="//cdn.jsdelivr.net/npm/inferno@7.3.3/dist/inferno.min.js"></script>
    <script src="//cdn.jsdelivr.net/npm/inferno-create-element@7.3.3/dist/inferno-create-element.min.js"></script>
</head>
<body onload="javascript: onload();">
    <div id="app" ref="app">
        <div class="box">
            <!-- @canplay="loadedmetadata($event)" -->
            <!-- preload autoplay controls  -->
            <!-- playsinline webkit-playsinline x5-playsinline  -->
            <!-- x5-video-player-type="h5" x5-video-player-fullscreen="ture" -->
            <video ref="video" class="video" @loadedmetadata="loadedmetadata($event)" @play="play($event)" @pause="pause($event)" @ended="pause($event)" autoplay></video>
            <img ref="image" class="image" @load="imgLoaded($event)" />
            <canvas ref="canvas" class="canvas"></canvas>
        </div>
        <div id="view" ref="view" v-html="content" :class="viewClass" :style="viewStyle"></div>
        <div id="tool" ref="tool">
            <input id="file" ref="file" type="file" @change="fileChange($event)" hidden>
            <button type="button" @click="$refs.file.click();">图片/视频</button>
            <button type="button" @click="videoPlay($event)">播放/暂停</button>
        </div>
    </div>
    <script src="js/script.js"></script>
</body>
</html>

2.2. css/style.css

:root{
    --pic-view-bg: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBvbHlnb24gcG9pbnRzPSIwLDAgMTAsMCAxMCwxMCAyMCwxMCAyMCwyMCAxMCwyMCAxMCwxMCAwLDEwIiBzdHlsZT0iZmlsbDpyZ2JhKDEzOCwxMzgsMTM4LDAuMyk7c3Ryb2tlLXdpZHRoOjAiLz48L3N2Zz4=");
}
::-webkit-scrollbar{ display: none; }
/*  */
html{ font-family: monospace; background: #000 var(--pic-view-bg) fixed; }
html,body,#app{ margin: 0px; padding: 0px; width: 100%; height: 100%; overflow: hidden; }
#app{ display: flex; align-items: center; justify-content: center; background-color: rgba(0,0,0,0.25); color: #fff; }
.box{ display: none; opacity: 0.5; }
.video{ position: absolute; left: 0px; top: 0px; max-width: 6vw; max-height: 12vh; }
.image{ position: absolute; left: 0px; bottom: 0px; max-width: 6vw; max-height: 12vh; }
.canvas{ position: fixed; right: 0px; top: 0px; }
/* position: fixed; */
#view{
    /* transform: scale(0.8); width: 100%; */
    bottom: 0px; overflow: hidden; z-index: 2;
    font-family: monospace; font-size: 12px; line-height: 1em; letter-spacing:0px; word-spacing:0px; text-align: center;
}
#tool{ position: fixed; bottom: 0px; right: 0px; padding: 10px; text-align: right; z-index: 10; }
.stats{ position: fixed; top: 0px; z-index: 100; }
/* 红色阴影特效 */
.shadow{ text-shadow: 2px -1px 1px #f00a; }
/* 颜色反转 */
.reverse{ background: #fff; color: #000; }

2.3. js/script.html

const getImageBlob = function(url, callback) {
    var xhr = new XMLHttpRequest();
    xhr.open('get', url, true);
    xhr.responseType = 'blob';
    xhr.onload = function() {
        if(this.status === 200 && callback instanceof Function) callback(URL.createObjectURL(this.response));
    };
    xhr.send();
}

// 页面body加载完成
function onload(){
    const UA = navigator.userAgent;
    const isAndroid = UA.indexOf('Android') > -1 || UA.indexOf('Adr') > -1; //android终端
    const space = isAndroid? '&nbsp;' : '&ensp;';
    // 利用vue虚拟DOM技术加速DOM节点数据渲染
    var v$app = window.v$app = new Vue({
        el: "#app",
        data: {
            src: "",
            flvsrc: "//aliyun-flv.yy.com/live/15013_xv_22490906_22490906_0_0_0-15013_xa_22490906_22490906_0_0_0-96597708953498332-96597708953498333-2-2748477-33.flv?codec=orig&secret=bec0e1c80fad166895855545ff4efc89&t=1562310185&appid=15013",
            m3u8src: "//ivi.bupt.edu.cn/hls/cctv10.m3u8",
            content: null, // 视图html内容
            timer: null, // 定时器索引
            range: document.createRange(), // 用于通过TagString创建虚拟dom(DocumentFragment)节点
            stats: new Stats(), // 性能监视器:含fps、耗时ms、内存分配
            showStats: !!url("?showStats"), // 显示统计信息
            enableColor: !!url("?enableColor"), // 启用输出色彩
            enableReverse: !!url("?enableReverse"), // 启用色彩反转
            // 拉伸/自适应
            fps: 30, // fps(流畅度)
            fontSize: 12, // 视图容器字体大小
            chars: [space, '.', ':', ';', '!', 'i', 'c', 'e', 'm', '@'], // 映射字符集;
            styleTemplate: doT.template('color: rgb({{=it.R}},{{=it.G}},{{=it.B}});'), // 彩色字符style模板
            spanTemplate: doT.template('<span style="color:rgb({{=it.R}},{{=it.G}},{{=it.B}});">{{=it.T}}</span>'), // 彩色字符模板
            sw: document.body.offsetWidth, sh: document.body.offsetHeight, // 存储屏幕宽高(含初始化)
            sourceScale: 1, // 默认素材宽高比
            currRowTempFn: null, // 行模板
            currFrameTempFn: null, // 帧模板
        },
        // 动态计算
        computed:{
            // 配置灰度字符映射表
            charMap: function() {
                var chars = !this.enableReverse ? this.chars : this.chars.reverse();
                var len = 256, step = ~~(len/(chars.length-1)); // 映射步长=最大字符长度/映射字符长度
                return Array.apply(!0, Array(len)).map(function(v,i,c){
                    return chars[~~(i / step)];
                });
            },
            // 屏幕宽高比
            screenScale: function() {
                return this.sw / this.sh;
            },
            // 屏幕允许最大行数
            maxRow: function() {
                return ~~(this.sh / this.fontSize);
            },
            // 屏幕允许最大列数
            maxCol: function() {
                var fontWidth = this.fontSize / 2;
                return ~~(this.sw / fontWidth);
            },
            // 画面帧间隔时间ms
            fpsStep: function() {
                return 1000 / this.fps;
            },
            viewClass: function() {
                var className = url("?className");
                if(!Array.isArray(className)) className = [className];
                className.push({
                    reverse: url("?enableReverse") // 反转色彩
                });
                return className;
            },
            viewStyle: function() {
                var style = url("?style");
                return style ? JSON.parse(style) : undefined;
            },
        },
        mounted: function() {
            this.$nextTick(function() {
                this.src = url("?src") || "video/v.mp4";
                this.initStats(); // 初始化统计工具
                window.onresize = this.resetToCharsConfig; // 窗口大小改变
            });
        },
        // 数据监听
        watch: {
            src: function(nv, ov) {
                var video = this.$refs.video, canvas = this.$refs.canvas;
                this.timer ? clearInterval(this.timer) : null; // 移除定时器
                var ctx = canvas.getContext('2d');
                ctx.clearRect(0, 0, canvas.width, canvas.height); // 清除画布
                var ext = url("fileext", nv);
                switch(String(ext).toLowerCase()) {
                    case "flv": this.loadFlv(nv, ext); break;
                    case "m3u8": this.loadHls(nv, ext); break;
                    case "jpg": this.loadImage(nv, ext); break;
                    case "png": this.loadImage(nv, ext); break;
                    case "gif": this.loadImage(nv, ext); break;
                    default: video.src = nv; break;
                }
                this.$nextTick(function() {
                    video.load();
                });
            },
            enableColor: function(nv, ov) {
                this.resetToCharsConfig();
            }
        },
        methods: {
            // 加载Flv链接地址
            loadFlv: function(src, callback) {
                var video = this.$refs.video;
                if(flvjs.isSupported()) {
                    var flvPlayer = flvjs.createPlayer({ type: 'flv', url: src });
                    flvPlayer.attachMediaElement(video);
                    flvPlayer.load();
                    video.load();
                    // flvPlayer.play();
                    if(callback instanceof Function) callback(flvPlayer);
                }
            },
            // 加载Hls链接地址(m3u8)
            loadHls: function(src, callback) {
                var video = this.$refs.video;
                if(Hls.isSupported()) {
                    var hls = new Hls();
                    hls.loadSource(src);
                    hls.attachMedia(video);
                    video.load();
                    if(callback instanceof Function) callback(hls);
                }
            },
            // 加载静态图片链接地址
            loadImage: function(src, callback) {
                var image = this.$refs.image;
                getImageBlob(src, function(url) {
                    image.src = url;
                });
            },
            // 渲染帧数据
            renderFrame: function(frameData) {
                var enableColor = this.enableColor, spanTemplate = this.spanTemplate;
                return frameData.map(function(rowData) {
                    return rowData.map(function(v) {
                        return enableColor ? spanTemplate(v) : v.T;
                    }).join('');
                }).join('<br/>\n');
            },
            // 实时生成行模板
            rowTempFn: function(rowData) {
                var canvas = this.$refs.canvas, templates = [];
                if(this.enableColor) {
                    for(var i = 0; i < canvas.width; i += 1) {
                        templates.push('<span style="color:rgb({{=it['+i+'].R}},{{=it['+i+'].G}},{{=it['+i+'].B}});">{{=it['+i+'].T}}</span>');
                    }
                } else {
                    for(var i = 0; i < canvas.width; i += 1) {
                        templates.push('{{=it['+i+'].T}}');
                    }
                }
                return doT.template(templates.join(''));
            },
            // 实时生成帧模板
            frameTempFn: function() {
                var canvas = this.$refs.canvas, templates = [];
                if(this.enableColor) {
                    for(var i = 0; i < canvas.height; i += 1) {
                        for(var j = 0; j < canvas.width; j += 1) {
                            templates.push('<span style="color:rgb({{=it['+i+']['+j+'].R}},{{=it['+i+']['+j+'].G}},{{=it['+i+']['+j+'].B}});">{{=it['+i+']['+j+'].T}}</span>');
                        }
                        templates.push('<br/>\n');
                    }
                } else {
                    for(var i = 0; i < canvas.height; i += 1) {
                        for(var j = 0; j < canvas.width; j += 1) {
                            templates.push('{{=it['+i+']['+j+'].T}}');
                        }
                        templates.push('<br/>\n');
                    }
                }
                return doT.template(templates.join(''));
            },
            // 重置采集参数
            resetToCharsConfig: function() {
                var canvas = this.$refs.canvas, app = this.$refs.app;
                // 采集屏幕宽高
                this.sw = app.offsetWidth;
                this.sh = app.offsetHeight;
                // console.log("最大允许 宽:%s 高:%s ", this.maxCol, this.maxRow);
                // console.log("素材比屏幕宽?(%s) 素材宽高比:%s 屏幕宽高比:%s", this.sourceScale>this.screenScale, this.sourceScale, this.screenScale);
                // 拉伸模式
                // canvas.width = this.maxCol;
                // canvas.height = this.maxRow;

                // 自适应模式
                if(this.sourceScale > this.screenScale) {
                    canvas.width = this.maxCol;// 宽度自适应
                    // 在宽度自适应情况下高度/2与宽度保持比例(因字体高度是宽度的2倍, 为保证画面与素材保持正确比例)
                    canvas.height = this.maxCol / this.sourceScale / 2;
                }else{
                    canvas.height = this.maxRow;// 高度自适应
                    // 在高度自适应情况下宽度*2与高度保持比例(因字体高度是宽度的2倍, 为保证画面与素材保持正确比例)
                    canvas.width = this.maxRow * this.sourceScale * 2;
                }

                // console.log("最终canvas宽高", canvas.width, canvas.height);
                this.currRowTempFn = this.rowTempFn(); // 生成行模版
                this.currFrameTempFn = this.frameTempFn(); // 生成帧模版
            },
            // 绘制canvas
            drawCanvas: function(ctx, ele) {
                const canvas = this.$refs.canvas;
                ctx.drawImage(ele, 0, 0, canvas.width, canvas.height); // 绘制图像
                this.toFrameData(ctx, canvas.width, canvas.height, this.update); // 将画布图像数据转换为字符画
            },
            // 图像转字符画数据
            toFrameData: function(ctx, cw, ch, callback) {
                const canvas = this.$refs.canvas;
                const styleTemplate = this.styleTemplate;
                var image = ctx.getImageData(0, 0, canvas.width, canvas.height);
                var imgDate = image.data ; // 当前画布图像数据
                // 遍历每个字符画像素获取灰度值映射字符追加至字符画帧数据
                var rowArray = [], rowVNodes = [];
                for(var i = 0, idx = 0; i < image.height; i += 1) {
                    var colArray = [], colVNodes = [];
                    for(var j = 0; j < image.width; j += 1, idx += 4) {
                        var p = { R: 0, G: 0, B: 0 };
                        p.R = ~~imgDate[idx], p.G = ~~imgDate[idx+1], p.B = ~~imgDate[idx+2];
                        // 获取区域平均灰度及平均RGB色彩值 为提高效率将单像素灰度计算中的除以100提出
                        // https://www.cnblogs.com/zhangjiansheng/p/6925722.html
                        var Gray = (p.R*38 + p.G*75 + p.B*15) >> 7;
                        p.T = this.charMap[Gray]; // 映射灰度字符
                        colArray.push(p); // 行数据
                        colVNodes.push(Inferno.createElement('span', { style: styleTemplate(p) }, Inferno.createTextVNode(p.T)));
                    }
                    rowArray.push(colArray); // 帧数据
                    rowVNodes.push(Inferno.createElement('div', null, colVNodes));
                };
                var VNode = Inferno.createElement('div', null, rowVNodes);
                if(callback instanceof Function) callback(rowArray, VNode);
            },
            // 更新画面
            update: function(frameData, frameVNode) {
                var _this = this, view = this.$refs.view;
                // 方法一 行模板渲染(相较方法二兼容更多浏览器,不易发生栈溢出)
                var frame = frameData.map(function(v) {
                    return _this.currRowTempFn(v);
                }).join("<br/>\n");
                // 方法二 帧模板渲染(效率高但兼容差易超出堆栈上限: Maximum call stack size exceeded)
                // var frame = this.currFrameTempFn(frameData);
                // 方法三 字符模板渲染(效率仅次于方法一,兼容性好);
                // var frame = this.renderFrame(frameData);
                // 方法四 fragment预加载渲染(无法清除旧的innerHtml)
                // view.innerHtml = null;
                // view.appendChild(this.range.createContextualFragment(frame));
                // 方法五 Inferno差异化渲染(当前场景效率低)
                // Inferno.render(frameVNode, view);
                this.content = frame; // 渲染画面
                this.$nextTick(function() {
                    this.stats.update(); // 触发性能统计
                });
            },
            // 初始化统计工具
            initStats: function() {
                var tool = this.$refs.tool, statsEle = this.stats.domElement;
                if(this.showStats && tool && statsEle) {
                    statsEle.className = "stats";
                    tool.appendChild(statsEle);
                }
            },

            // vue事件
            // fileChange 文件更改时修改视频源
            fileChange: function(e) {
                var file = this.$refs.file, image = this.$refs.image;
                if(file.files[0]) {
                    this.src = URL.createObjectURL(file.files[0]);
                    // 兼容图片
                    var type = file.files[0].type;
                    if(type.split("/")[0] === "image") {
                        image.src = this.src;
                        image.setAttribute("data-type", type);
                    }
                }
            },
            // imgLoaded 图片加载成功
            imgLoaded: function(e) {
                var image = this.$refs.image;
                this.sourceScale = image.width/image.height;
                this.resetToCharsConfig();
                // 开始渲染
                var _this = this, canvas = this.$refs.canvas;
                var ctx = canvas.getContext('2d');
                this.drawCanvas(ctx, image);
                // gif支持
                if(["image/gif"].indexOf(image.getAttribute("data-type")) !== -1) {
                    var rub = new SuperGif({ gif: image, progressbar_height: 0 });
                    rub.load(function() {
                        var gifCanvas = rub.get_canvas();
                        _this.timer = setInterval(function() {
                            _this.drawCanvas(ctx, gifCanvas);
                        }, _this.fpsStep);
                    });
                }
            },
            // canplay 媒体可播放
            // loadedmetadata 媒体元数据加载
            loadedmetadata: function(e) {
                var video = this.$refs.video;
                this.sourceScale = video.videoWidth/video.videoHeight || this.screenScale;
                this.resetToCharsConfig();
            },
            // play 视频播放事件
            play: function(e) {
                var _this = this, video = this.$refs.video, canvas = this.$refs.canvas;
                var ctx = canvas.getContext('2d');
                this.timer = setInterval(function() {
                    if(!video.paused) {
                        _this.drawCanvas(ctx, video);
                    }
                }, _this.fpsStep);
            },
            // pause 视频暂停/停止事件
            pause: function(e) {
                clearInterval(this.timer); // 视频暂停或结束停止定时器
                e.type === "ended" ? this.content=null : null; // 结束播放清除视图
            },
            // videoPlay 播放按钮点击事件
            videoPlay: function(e) {
                var video = this.$refs.video;
                video.paused ? video.play() : video.pause();
            },
        }
    });
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容