├── client └── geek_meeting │ ├── flutter_01.png │ ├── lib │ ├── utils │ │ ├── localStore.dart │ │ ├── global.dart │ │ ├── audio.dart │ │ ├── common.dart │ │ └── net.dart │ ├── route │ │ ├── routes_path.dart │ │ ├── middleware │ │ │ └── meeting.dart │ │ └── routes.dart │ ├── models │ │ ├── home.dart │ │ ├── metting │ │ │ ├── meeting_recond.dart │ │ │ ├── meeting_user.dart │ │ │ ├── meeting.dart.bak │ │ │ └── meeting_room.dart │ │ ├── login.dart │ │ ├── create_meeting.dart │ │ └── setting.dart │ ├── components │ │ ├── dialog │ │ │ ├── agreement.dart │ │ │ ├── room_recond.dart │ │ │ ├── join_room.dart │ │ │ └── create_room.dart │ │ ├── meeting_list.dart │ │ ├── setting.dart │ │ └── dialog.dart.bak │ ├── main.dart │ └── pages │ │ ├── home.dart │ │ ├── meeting.dart │ │ └── login.dart │ ├── .metadata │ ├── README.md │ ├── .gitignore │ ├── test │ └── widget_test.dart │ ├── analysis_options.yaml │ ├── pubspec.yaml │ └── pubspec.lock ├── server └── GeekMeeting │ ├── .gitignore │ ├── core │ └── sfu │ │ └── rooms │ │ ├── user.go │ │ ├── signal.go │ │ ├── rooms.go │ │ └── room.go │ ├── api │ ├── middleware │ │ ├── error.go │ │ └── auth.go │ ├── handler │ │ ├── meeting │ │ │ ├── signal │ │ │ │ ├── types.go │ │ │ │ └── signal.go │ │ │ ├── record.go │ │ │ ├── create.go │ │ │ └── join.go │ │ ├── token │ │ │ └── refresh.go │ │ └── login │ │ │ ├── send_code.go │ │ │ └── verify.go │ └── router │ │ └── router.go │ ├── internal │ ├── util │ │ ├── crypto.go │ │ ├── converts.go │ │ ├── mail.go │ │ ├── coder.go │ │ └── jwt.go │ ├── sqltime │ │ └── sqltime.go │ ├── type │ │ ├── itypes.go │ │ └── ierror.go │ └── config │ │ └── config.go │ ├── services │ ├── database │ │ ├── sqlc.yaml │ │ ├── mysql │ │ │ ├── mysql.go │ │ │ └── meeting │ │ │ │ ├── db.go │ │ │ │ ├── models.go │ │ │ │ └── query.sql.go │ │ ├── query.sql │ │ └── schema.sql │ ├── auth │ │ └── jwtInterface.go │ └── cache │ │ ├── cache.go │ │ └── redis │ │ └── redis.go │ ├── tests │ ├── context_test.go │ └── jwt_test.go │ ├── config.toml.example │ ├── README.md │ ├── meet.go │ └── go.mod ├── screenshot ├── 1640849131114.png ├── 16408490465958.png ├── 16408490644944.png ├── 16408491047563.png ├── 16408491202045.png ├── 16408491264541.png └── 20211230154043.png ├── README.md └── LICENSE /client/geek_meeting/flutter_01.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/utils/localStore.dart: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/GeekMeeting/.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | config.toml -------------------------------------------------------------------------------- /screenshot/1640849131114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/t924417424/geek_meeting_source/HEAD/screenshot/1640849131114.png -------------------------------------------------------------------------------- /screenshot/16408490465958.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/t924417424/geek_meeting_source/HEAD/screenshot/16408490465958.png -------------------------------------------------------------------------------- /screenshot/16408490644944.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/t924417424/geek_meeting_source/HEAD/screenshot/16408490644944.png -------------------------------------------------------------------------------- /screenshot/16408491047563.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/t924417424/geek_meeting_source/HEAD/screenshot/16408491047563.png -------------------------------------------------------------------------------- /screenshot/16408491202045.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/t924417424/geek_meeting_source/HEAD/screenshot/16408491202045.png -------------------------------------------------------------------------------- /screenshot/16408491264541.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/t924417424/geek_meeting_source/HEAD/screenshot/16408491264541.png -------------------------------------------------------------------------------- /screenshot/20211230154043.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/t924417424/geek_meeting_source/HEAD/screenshot/20211230154043.png -------------------------------------------------------------------------------- /server/GeekMeeting/core/sfu/rooms/user.go: -------------------------------------------------------------------------------- 1 | package rooms 2 | 3 | type UserEvent struct { 4 | Uid int64 `json:"uid"` 5 | StreamID string `json:"streamId"` 6 | Name string `json:"name"` 7 | } 8 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/route/routes_path.dart: -------------------------------------------------------------------------------- 1 | abstract class RoutesPath { 2 | static const Initial = '/'; 3 | static const Login = '/login'; 4 | static const Home = '/home'; 5 | static const Meeting = '/meeting'; 6 | } 7 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/utils/global.dart: -------------------------------------------------------------------------------- 1 | import 'package:geek_meeting/utils/net.dart'; 2 | 3 | class NetUtil { 4 | static late Net _net; 5 | static Net get net => _net; 6 | static init() { 7 | _net = Net(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/GeekMeeting/api/middleware/error.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | ) 7 | 8 | func PaincHandle(rw http.ResponseWriter, r *http.Request, i interface{}) { 9 | log.Println(i) 10 | rw.WriteHeader(http.StatusInternalServerError) 11 | _, _ = rw.Write(nil) 12 | } 13 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/route/middleware/meeting.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:get/get.dart'; 3 | 4 | class MeetingMiddleware extends GetMiddleware { 5 | // MeetingMiddleware(); 6 | @override 7 | RouteSettings? redirect(String? route) { 8 | debugPrint(Get.arguments); 9 | return null; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /server/GeekMeeting/internal/util/crypto.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | ) 7 | 8 | func Md5(str string) string { 9 | m := md5.Sum([]byte(str)) 10 | return hex.EncodeToString(m[:]) 11 | } 12 | 13 | func Md5FromBytes(data []byte) string { 14 | m := md5.Sum(data) 15 | return hex.EncodeToString(m[:]) 16 | } 17 | -------------------------------------------------------------------------------- /server/GeekMeeting/services/database/sqlc.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | packages: 3 | - name: "meetingDb" 4 | path: "./mysql/meeting" 5 | schema: "schema.sql" 6 | queries: "query.sql" 7 | engine: "mysql" 8 | emit_json_tags: true 9 | overrides: 10 | - go_type: "GeekMeeting/internal/sqltime.NullTime" 11 | db_type: "timestamp" 12 | nullable: true -------------------------------------------------------------------------------- /server/GeekMeeting/services/auth/jwtInterface.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | type JwtTool interface { 4 | GetKey() []byte 5 | SetKey(key []byte) 6 | NewToken(userId int64) (accessToken, refreshToken string, err error) 7 | ParseToken(tokenStr string) (int64, bool, error) 8 | RefreshToken(tokenStr, oldToken string) (accessToken, refreshToken string, err error) 9 | } 10 | -------------------------------------------------------------------------------- /client/geek_meeting/.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 18116933e77adc82f80866c928266a5b4f1ed645 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /server/GeekMeeting/internal/util/converts.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "unsafe" 5 | ) 6 | 7 | // BytesToString converts byte slice to string. 8 | func BytesToString(b []byte) string { 9 | return *(*string)(unsafe.Pointer(&b)) 10 | } 11 | 12 | // StringToBytes converts string to byte slice. 13 | func StringToBytes(s string) []byte { 14 | return *(*[]byte)(unsafe.Pointer( 15 | &struct { 16 | string 17 | Cap int 18 | }{s, len(s)}, 19 | )) 20 | } 21 | -------------------------------------------------------------------------------- /server/GeekMeeting/services/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | type Cache interface { 9 | Set(ctx context.Context, key string, value interface{}) error 10 | Get(ctx context.Context, key string) ([]byte, error) 11 | SetEx(ctx context.Context, key string, value interface{}, expiration time.Duration) error 12 | IsExist(ctx context.Context, key string) bool 13 | DelKey(ctx context.Context, key string) error 14 | } 15 | -------------------------------------------------------------------------------- /server/GeekMeeting/tests/context_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | my_types "GeekMeeting/internal/type" 5 | "context" 6 | "testing" 7 | ) 8 | 9 | func TestContextType(t *testing.T) { 10 | type e int 11 | var i e = 0 12 | ctx := context.WithValue(context.WithValue(context.Background(), i, "1"), my_types.DatabasesKey, "2") 13 | if ctx.Value(i) == "1" { 14 | t.Log(ctx.Value(0)) 15 | } else { 16 | t.Error() 17 | } 18 | if ctx.Value(my_types.DatabasesKey) == "2" { 19 | t.Log(ctx.Value(0)) 20 | } else { 21 | t.Error() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Geek云视频会议项目开源仓库 2 | 3 | ## 项目简介 4 | * Geek云会议系统是一个基于webrtc的视频会议系统 5 | * 项目演示:[点击进入](https://meeting2.do18.cn/#/) 6 | * 平台支持 7 | 8 | * [x] Web 9 | * [ ] Android 10 | * [ ] Ios 11 | * [ ] Windows 12 | * [ ] Linux 13 | * 项目技术栈 14 | 15 | * 前端:Flutter 16 | * 后端:Go 17 | 18 | ## 项目截图 19 | ![](./screenshot/20211230154043.png) 20 | ![](./screenshot/1640849131114.png) 21 | ![](./screenshot/16408491202045.png) 22 | ![](./screenshot/16408491264541.png) 23 | ![](./screenshot/16408490644944.png) 24 | ![](./screenshot/16408491047563.png) 25 | ![](./screenshot/16408490465958.png) -------------------------------------------------------------------------------- /server/GeekMeeting/config.toml.example: -------------------------------------------------------------------------------- 1 | [server] 2 | [server.web] 3 | addr = "127.0.0.1:8080" 4 | jwtKey = "ABCASOIBOIBNFKLSJISDASFMPBIIO" 5 | maxTime = 30 # 每场会议最长时间(分钟) 6 | roomLimit = 5 # 每天每个用户最多可创建的会议数量 7 | roomPeople = 20 # 每场会议最大人数 8 | 9 | // 目前仅支持mysql 10 | [sql] 11 | [sql.databases] 12 | driver = "mysql" 13 | dsn = "root:root@tcp(127.0.0.1:3306)/meeting?charset=utf8&parseTime=True&loc=Local" 14 | 15 | # Redis设置 16 | [sql.cache] 17 | Addr = "127.0.0.1:6379" 18 | username = "" 19 | password = "" 20 | 21 | // smtp设置 22 | [mail] 23 | server = "smtp.xxx.com" 24 | port = 25 25 | name = "" # 建议与username保持一致 26 | username = "" 27 | password = "" -------------------------------------------------------------------------------- /server/GeekMeeting/services/database/mysql/mysql.go: -------------------------------------------------------------------------------- 1 | package meeting 2 | 3 | import ( 4 | "GeekMeeting/internal/config" 5 | itypes "GeekMeeting/internal/type" 6 | "context" 7 | "database/sql" 8 | "sync" 9 | 10 | _ "github.com/go-sql-driver/mysql" 11 | ) 12 | 13 | var ( 14 | sqlOnce sync.Once 15 | sqlDb *sql.DB 16 | ) 17 | 18 | func GetMysql(ctx context.Context) (db *sql.DB, err error) { 19 | sqlOnce.Do(func() { 20 | conf := ctx.Value(itypes.ConfigKey).(config.TomlMap) 21 | sqlDb, err = sql.Open(conf.SQL.Databases.Driver, conf.SQL.Databases.Dsn) 22 | if err != nil { 23 | panic(err) 24 | } 25 | }) 26 | return sqlDb, err 27 | } 28 | -------------------------------------------------------------------------------- /server/GeekMeeting/api/handler/meeting/signal/types.go: -------------------------------------------------------------------------------- 1 | package signal 2 | 3 | import ( 4 | "GeekMeeting/core/sfu/rooms" 5 | "net/http" 6 | 7 | "github.com/gorilla/websocket" 8 | "github.com/pion/webrtc/v3" 9 | ) 10 | 11 | var ( 12 | upgrader = websocket.Upgrader{ 13 | CheckOrigin: func(r *http.Request) bool { return true }, 14 | } 15 | ) 16 | 17 | type websocketMessage struct { 18 | Event string `json:"event"` 19 | Data string `json:"data"` 20 | } 21 | 22 | type userEvent rooms.UserEvent 23 | 24 | var peerConfig = webrtc.Configuration{ 25 | ICEServers: []webrtc.ICEServer{ 26 | { 27 | URLs: []string{"stun:stun.l.google.com:19302"}, 28 | }, 29 | }, 30 | } -------------------------------------------------------------------------------- /server/GeekMeeting/README.md: -------------------------------------------------------------------------------- 1 | # Geek云视频会议后端项目 2 | 3 | 基于webrtc的视频会议系统后端,此版本使用Go语言编写 4 | 5 | ## 功能 6 | - [x] SFU转发服务 7 | - [x] 会议房间基本功能 8 | * 邮箱验证码登陆 9 | * 房间时长限制 10 | * 自动关闭无效房间 11 | * 支持房间 有/无 密码进入 12 | * 房间最大人数限制 13 | 14 | ## 安装 15 | * 项目开发环境:go1.17 16 | 1. 安装go语言sdk 17 | 2. 获取仓库源代码 git clone ... 18 | 3. `go build meet.go` 19 | 4. [根据配置文件样例新建配置文件](./config.toml.example) 20 | 5. 将[数据库脚本](./services/database/schema.sql)导入mysql 21 | 6. ./meet --config [配置文件路径,默认问当前路径的config.toml文件] 22 | 23 | ## 关于 24 | 25 | - 此仓库为视频会议后端源码 26 | - 项目后端采用Go进行开发,开发优先采用Go语言进行开发,如无特殊需求,暂不考虑使用Rust重构后端项目 27 | - 因考虑到项目为多人会议系统,而非一对一视频通话,故项目并非采用传统webrtc的p2p架构,而是经由后端sfu服务器进行中转,故对服务器的带宽有一定的要求,后续计划根据参会人数来自动进行协议选择 28 | - 开发人员: 29 | - 联系QQ:924417424 -------------------------------------------------------------------------------- /server/GeekMeeting/services/database/mysql/meeting/db.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | 3 | package meetingDb 4 | 5 | import ( 6 | "context" 7 | "database/sql" 8 | ) 9 | 10 | type DBTX interface { 11 | ExecContext(context.Context, string, ...interface{}) (sql.Result, error) 12 | PrepareContext(context.Context, string) (*sql.Stmt, error) 13 | QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) 14 | QueryRowContext(context.Context, string, ...interface{}) *sql.Row 15 | } 16 | 17 | func New(db DBTX) *Queries { 18 | return &Queries{db: db} 19 | } 20 | 21 | type Queries struct { 22 | db DBTX 23 | } 24 | 25 | func (q *Queries) WithTx(tx *sql.Tx) *Queries { 26 | return &Queries{ 27 | db: tx, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/route/routes.dart: -------------------------------------------------------------------------------- 1 | import 'package:geek_meeting/pages/home.dart'; 2 | import 'package:geek_meeting/pages/login.dart'; 3 | import 'package:geek_meeting/pages/meeting.dart'; 4 | import 'package:geek_meeting/route/routes_path.dart'; 5 | import 'package:get/get.dart'; 6 | 7 | abstract class Routes { 8 | static final pages = [ 9 | GetPage( 10 | name: RoutesPath.Initial, 11 | page: () => Login(), 12 | ), 13 | GetPage( 14 | name: RoutesPath.Login, 15 | page: () => Login(), 16 | ), 17 | GetPage( 18 | name: RoutesPath.Home, 19 | page: () => Home(), 20 | ), 21 | GetPage( 22 | name: RoutesPath.Meeting, 23 | page: () => Meeting(), 24 | // middlewares: [MeetingMiddleware()], 25 | ), 26 | ]; 27 | } 28 | -------------------------------------------------------------------------------- /server/GeekMeeting/internal/sqltime/sqltime.go: -------------------------------------------------------------------------------- 1 | package sqltime 2 | 3 | import ( 4 | "database/sql/driver" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | type NullTime struct { 10 | Time time.Time 11 | Valid bool // 是否有值 12 | } 13 | 14 | //实现它的赋值方法(注意,这个方属于指针) 15 | func (nt *NullTime) Scan(value interface{}) error { 16 | nt.Time, nt.Valid = value.(time.Time) 17 | return nil 18 | } 19 | 20 | //实现它的取值方式 21 | func (nt NullTime) Value() (driver.Value, error) { 22 | if !nt.Valid { 23 | return nil, nil 24 | } 25 | return nt.Time, nil 26 | } 27 | 28 | func (this NullTime) MarshalJSON() ([]byte, error) { 29 | if this.Valid { 30 | var stamp = fmt.Sprintf("\"%s\"", time.Time(this.Time).Format("2006-01-02 15:04:05")) 31 | return []byte(stamp), nil 32 | } else { 33 | return nil, nil 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /server/GeekMeeting/tests/jwt_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "GeekMeeting/internal/util" 5 | "testing" 6 | ) 7 | 8 | var ( 9 | myToken = util.NewJwt([]byte{4, 48, 64, 98, 6, 87, 1, 8, 7, 4, 84, 48}) 10 | uid int64 = 1 11 | ) 12 | 13 | func TestNewJwt(t *testing.T) { 14 | a, r, err := myToken.NewToken(uid) 15 | if err != nil { 16 | t.Error(err) 17 | } 18 | t.Log("access_token:", a) 19 | t.Log("refresh_token", r) 20 | pa, vaild, err := myToken.ParseToken(a) 21 | if err != nil { 22 | t.Error(err) 23 | } 24 | t.Log("claims:", pa) 25 | t.Log("vaild:", vaild) 26 | if pa != uid { 27 | t.Error() 28 | } 29 | } 30 | 31 | func TestPase(t *testing.T) { 32 | t.Log(myToken.ParseToken("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEsInR5cGUiOjAsImV4cCI6MTY0MDMyNzY1NX0.7wqVyWnGAOQ4I2S5u4dhZTnY4k_BRILv-ELKKV_R_H0")) 33 | } 34 | -------------------------------------------------------------------------------- /client/geek_meeting/README.md: -------------------------------------------------------------------------------- 1 | # geek_meeting 2 | 3 | 基于webrtc的视频会议系统前端,目前只支持完整功能发布到web端,其余端待适配 4 | 5 | ## dependencies 6 | - flutter_webrtc 7 | - get 8 | 9 | ## Future 10 | - [x] SFU_SERVER 11 | - [x] AddTrack 12 | - [ ] [P2P to SFU](https://webrtc.org.cn/20191022-sfu-p2p/) 13 | 14 | ## Install 15 | * 安装flutter环境(项目已兼容NullSafe) 16 | * 修改项目后端地址: 17 | 18 | * [后端接口服务器地址](./lib/utils/net.dart#L29) 19 | * [后端信令服务器地址](./lib/models/metting/meeting_room.dart#L19) 20 | * [iceServer修改(stun/turn服务)](./lib/models/metting/meeting_room.dart#L208) 21 | * 构建项目到web端:`flutter build web` 22 | * 由于浏览器安全策略,要求在非本地IP的时候需要前后端必须同为https/wss才可以正常使用 23 | 24 | ## about 25 | 26 | - 此仓库为视频会议前端源码,项目采用flutter web进行开发,故简单修改即可支持对端发布 27 | - 项目后端采用Go/Rust进行开发,开发优先采用Go语言进行开发,如无特殊需求,暂不考虑使用Rust重构后端项目 28 | - 因考虑到项目为多人会议系统,而非一对一视频通话,故项目并非采用传统webrtc的p2p架构,而是经由后端sfu服务器进行中转,故对服务器的带宽有一定的要求,后续计划根据参会人数来自动进行协议选择 29 | - 开发人员: 30 | - 联系QQ:924417424 31 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/models/home.dart: -------------------------------------------------------------------------------- 1 | import 'package:get/get.dart'; 2 | 3 | enum err { roomIdErr, roomNotFound, passWordError } 4 | 5 | class HomeModel extends GetxController { 6 | String? _errInfo; 7 | String? _userNameErr; 8 | bool _passWordErr = false; 9 | 10 | String? get errMsg => _errInfo; 11 | 12 | String? get nameErr => _userNameErr; 13 | 14 | bool get passWordErr => _passWordErr; 15 | 16 | set nameErr(String? msg) { 17 | _userNameErr = msg; 18 | update(); 19 | } 20 | 21 | set passWordErr(bool b) { 22 | _passWordErr = b; 23 | update(); 24 | } 25 | 26 | set errType(err? e) { 27 | String? msg; 28 | switch (e) { 29 | case err.roomIdErr: 30 | msg = "RoomID不正确"; 31 | break; 32 | case err.roomNotFound: 33 | msg = "会议未找到"; 34 | break; 35 | default: 36 | msg = null; 37 | } 38 | _errInfo = msg; 39 | update(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/models/metting/meeting_recond.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter_easyloading/flutter_easyloading.dart'; 4 | import 'package:geek_meeting/utils/global.dart'; 5 | import 'package:get/get.dart'; 6 | 7 | class MeetingRecond extends GetxController { 8 | int _page = 1; 9 | bool isLoading = false; 10 | final List _recond = []; 11 | List get recond => _recond; 12 | void fetch() { 13 | EasyLoading.show(dismissOnTap: true); 14 | isLoading = true; 15 | NetUtil.net.get("/recond", data: {"page": _page}, success: (data) { 16 | Map result = jsonDecode(data); 17 | isLoading = false; 18 | if (result["code"] == 200 && result["data"] != null) { 19 | _page += 1; 20 | _recond.addAllIf(result["data"] is List, result["data"]); 21 | update(); 22 | } 23 | }); 24 | EasyLoading.dismiss(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/GeekMeeting/internal/type/itypes.go: -------------------------------------------------------------------------------- 1 | package itypes 2 | 3 | import "encoding/json" 4 | 5 | type contextKey int 6 | 7 | const ( 8 | TimeFormat = "2006-01-02 15:04:05" 9 | DatabasesKey contextKey = iota 10 | CacheKey 11 | ConfigKey 12 | UserId 13 | JwtKey 14 | Rooms 15 | RoomsId 16 | UserName 17 | ) 18 | 19 | // type Middleware 20 | 21 | type JoinInfo struct { 22 | RoomId int64 23 | UserId int64 24 | UserName string 25 | } 26 | 27 | type resp struct { 28 | Code int `json:"code"` 29 | Msg string `json:"msg"` 30 | Data interface{} `json:"data"` 31 | Token struct { 32 | AccessToken string `json:"access_token"` 33 | RefreshToken string `json:"refresh_token"` 34 | } `json:"token"` 35 | } 36 | 37 | func NewResp() resp { 38 | return resp{} 39 | } 40 | 41 | func (r resp) ToJson() ([]byte, error) { 42 | b, err := json.Marshal(r) 43 | if err != nil { 44 | return nil, MarshalErr 45 | } 46 | return b, nil 47 | } 48 | -------------------------------------------------------------------------------- /client/geek_meeting/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | **/ios/Flutter/.last_build_id 26 | .dart_tool/ 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | .packages 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | /android/ 34 | /ios/ 35 | /web/ 36 | /windows/ 37 | 38 | # Web related 39 | lib/generated_plugin_registrant.dart 40 | 41 | # Symbolication related 42 | app.*.symbols 43 | 44 | # Obfuscation related 45 | app.*.map.json 46 | 47 | # Android Studio will place build artifacts here 48 | /android/app/debug 49 | /android/app/profile 50 | /android/app/release 51 | -------------------------------------------------------------------------------- /server/GeekMeeting/services/database/mysql/meeting/models.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | 3 | package meetingDb 4 | 5 | import ( 6 | "database/sql" 7 | "time" 8 | 9 | "GeekMeeting/internal/sqltime" 10 | ) 11 | 12 | // 参会记录 13 | type Recond struct { 14 | ID int64 `json:"id"` 15 | UserID int64 `json:"user_id"` 16 | // 会议昵称 17 | Name sql.NullString `json:"name"` 18 | // 会议ID 19 | RoomID int64 `json:"room_id"` 20 | } 21 | 22 | // 会议房间 23 | type Room struct { 24 | ID int64 `json:"id"` 25 | StartTime sqltime.NullTime `json:"start_time"` 26 | EndTime sqltime.NullTime `json:"end_time"` 27 | // 会议密码 28 | Password string `json:"password"` 29 | MasterID int64 `json:"master_id"` 30 | CreateTime sqltime.NullTime `json:"create_time"` 31 | // 不做任何功能,用于查询结果添加字段 32 | Expand string `json:"expand"` 33 | } 34 | 35 | type User struct { 36 | ID int64 `json:"id"` 37 | // 邮箱 38 | Email string `json:"email"` 39 | CreateTime time.Time `json:"create_time"` 40 | } 41 | -------------------------------------------------------------------------------- /server/GeekMeeting/api/router/router.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "GeekMeeting/api/handler/login" 5 | "GeekMeeting/api/handler/meeting" 6 | "GeekMeeting/api/handler/meeting/signal" 7 | "GeekMeeting/api/handler/token" 8 | "GeekMeeting/api/middleware" 9 | "context" 10 | "net/http" 11 | 12 | "github.com/julienschmidt/httprouter" 13 | ) 14 | 15 | // 初始化Router 16 | func InitRouter(router *httprouter.Router, ctx context.Context) { 17 | router.PanicHandler = middleware.PaincHandle 18 | router.Handler(http.MethodGet, "/debug/pprof/*item", http.DefaultServeMux) 19 | router.POST("/send", login.SendCode(ctx)) 20 | router.POST("/verify", login.Verify(ctx)) 21 | router.POST("/refresh_token", token.Refresh(ctx)) 22 | router.POST("/create_meeting", middleware.AuthMiddleware(ctx, meeting.CreateMeeting)) 23 | router.POST("/join_meeting", middleware.AuthMiddleware(ctx, meeting.JoinMeeting)) 24 | router.GET("/recond", middleware.AuthMiddleware(ctx, meeting.MeetingRecord)) 25 | router.GET("/signal/:key", middleware.AuthMiddlewareSignal(ctx, signal.SignalHandler)) 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 OldCat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/geek_meeting/test/widget_test.dart: -------------------------------------------------------------------------------- 1 | // This is a basic Flutter widget test. 2 | // 3 | // To perform an interaction with a widget in your test, use the WidgetTester 4 | // utility that Flutter provides. For example, you can send tap and scroll 5 | // gestures. You can also use WidgetTester to find child widgets in the widget 6 | // tree, read text, and verify that the values of widget properties are correct. 7 | 8 | import 'package:flutter/material.dart'; 9 | import 'package:flutter_test/flutter_test.dart'; 10 | 11 | import 'package:geek_meeting/main.dart'; 12 | 13 | void main() { 14 | testWidgets('Counter increments smoke test', (WidgetTester tester) async { 15 | // Build our app and trigger a frame. 16 | await tester.pumpWidget(const MyApp()); 17 | 18 | // Verify that our counter starts at 0. 19 | expect(find.text('0'), findsOneWidget); 20 | expect(find.text('1'), findsNothing); 21 | 22 | // Tap the '+' icon and trigger a frame. 23 | await tester.tap(find.byIcon(Icons.add)); 24 | await tester.pump(); 25 | 26 | // Verify that our counter has incremented. 27 | expect(find.text('0'), findsNothing); 28 | expect(find.text('1'), findsOneWidget); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/models/login.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'package:get/get.dart'; 3 | 4 | class LoginModel extends GetxController { 5 | bool _userProtocol = false; 6 | bool _privacyProtocol = false; 7 | int _time = 120; 8 | bool _sending = false; 9 | bool get userProtocol => _userProtocol; 10 | bool get privacyProtocol => _privacyProtocol; 11 | 12 | set userProtocol(bool? e) { 13 | _userProtocol = e == null 14 | ? false 15 | : e == false 16 | ? e 17 | : true; 18 | update(); 19 | } 20 | 21 | set privacyProtocol(bool? e) { 22 | _privacyProtocol = e == null 23 | ? false 24 | : e == false 25 | ? e 26 | : true; 27 | update(); 28 | } 29 | 30 | int get time => _time; 31 | 32 | set time(int t) { 33 | _time = t; 34 | update(); 35 | } 36 | 37 | sendOk() { 38 | if (_sending) { 39 | return; 40 | } 41 | _sending = true; 42 | Timer.periodic(const Duration(seconds: 1), (timer) { 43 | // 只在倒计时结束时回调 44 | if (_time > 0) { 45 | time -= 1; 46 | } else { 47 | time = 120; 48 | timer.cancel(); 49 | _sending = false; 50 | } 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /server/GeekMeeting/api/handler/token/refresh.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | itypes "GeekMeeting/internal/type" 5 | "GeekMeeting/services/auth" 6 | "context" 7 | "log" 8 | "net/http" 9 | "strings" 10 | 11 | "github.com/julienschmidt/httprouter" 12 | ) 13 | 14 | // token刷新接口 15 | func Refresh(ctx context.Context) httprouter.Handle { 16 | jwt := ctx.Value(itypes.JwtKey).(auth.JwtTool) 17 | return func(rw http.ResponseWriter, r *http.Request, p httprouter.Params) { 18 | authorization := r.Header.Get("Authorization") 19 | // log.Println(authorization) 20 | if !strings.HasPrefix(authorization, "Bearer ") { 21 | rw.WriteHeader(http.StatusUnauthorized) 22 | return 23 | } 24 | authorization = authorization[7:] 25 | refresh := r.FormValue("refresh_token") 26 | accessToken, refreshToken, err := jwt.RefreshToken(refresh, authorization) 27 | if err != nil { 28 | log.Println("NewToken:", err) 29 | rw.WriteHeader(http.StatusUnauthorized) 30 | return 31 | } 32 | rsp := itypes.NoError.ToResp() 33 | rsp.Token.AccessToken = accessToken 34 | rsp.Token.RefreshToken = refreshToken 35 | resp, err := rsp.ToJson() 36 | if err != nil { 37 | log.Println(err) 38 | rw.WriteHeader(http.StatusUnauthorized) 39 | return 40 | } 41 | _, _ = rw.Write(resp) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/GeekMeeting/internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "sync" 7 | 8 | "github.com/BurntSushi/toml" 9 | ) 10 | 11 | var ( 12 | config TomlMap 13 | confOnce sync.Once 14 | ) 15 | 16 | type TomlMap struct { 17 | Server struct { 18 | Web struct { 19 | Addr string `toml:"addr"` 20 | JwtKey string `toml:"jwtKey"` 21 | MaxTime int64 `toml:"maxTime"` 22 | RoomLimit int64 `toml:"roomLimit"` 23 | RoomPeople int `toml:"roomPeople"` 24 | } `toml:"web"` 25 | } `toml:"server"` 26 | SQL struct { 27 | Databases struct { 28 | Driver string `toml:"driver"` 29 | Dsn string `toml:"dsn"` 30 | } `toml:"databases"` 31 | Cache struct { 32 | Addr string `toml:"addr"` 33 | Username string `toml:"username"` 34 | Password string `toml:"password"` 35 | } `toml:"cache"` 36 | } `toml:"sql"` 37 | Mail struct { 38 | Server string `toml:"server"` 39 | Port int `toml:"port"` 40 | Name string `toml:"name"` 41 | Username string `toml:"username"` 42 | Password string `toml:"password"` 43 | } `toml:"mail"` 44 | } 45 | 46 | func GetConf(path string) TomlMap { 47 | confOnce.Do(func() { 48 | _, err := toml.DecodeFile(path, &config) 49 | if err != nil { 50 | log.Fatalf("%v", err) 51 | os.Exit(1) 52 | } 53 | }) 54 | return config 55 | } 56 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/components/dialog/agreement.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | // 用户协议Dialog 5 | Future showMyDiaLog(BuildContext context, String title, String text) { 6 | return showDialog( 7 | context: context, 8 | builder: (context) { 9 | return SimpleDialog( 10 | children: [ 11 | SizedBox( 12 | width: MediaQuery.of(context).size.height / 2, 13 | height: MediaQuery.of(context).size.height / 3, 14 | child: Scrollbar( 15 | child: SingleChildScrollView( 16 | padding: const EdgeInsets.all(20), 17 | child: Text(text), 18 | ), 19 | ), 20 | ), 21 | Row( 22 | mainAxisAlignment: MainAxisAlignment.end, 23 | children: [ 24 | InkWell( 25 | child: Padding( 26 | padding: const EdgeInsets.all(20), 27 | child: Text( 28 | "确定", 29 | style: TextStyle(color: Colors.blue[300], fontSize: 15), 30 | ), 31 | ), 32 | onTap: () => {Navigator.pop(context)}, 33 | ), 34 | ], 35 | ) 36 | ], 37 | ); 38 | }, 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_easyloading/flutter_easyloading.dart'; 3 | import 'package:flutter_localizations/flutter_localizations.dart'; 4 | import 'package:geek_meeting/pages/login.dart'; 5 | import 'package:geek_meeting/route/routes.dart'; 6 | import 'package:geek_meeting/route/routes_path.dart'; 7 | import 'package:geek_meeting/utils/global.dart'; 8 | import 'package:get/get.dart'; 9 | 10 | void main() { 11 | NetUtil.init(); 12 | runApp(const MyApp()); 13 | } 14 | 15 | class MyApp extends StatelessWidget { 16 | const MyApp({Key? key}) : super(key: key); 17 | 18 | // This widget is the root of your application. 19 | @override 20 | Widget build(BuildContext context) { 21 | return GetMaterialApp( 22 | title: 'Flutter Demo', 23 | initialRoute: RoutesPath.Initial, 24 | debugShowCheckedModeBanner: false, 25 | getPages: Routes.pages, 26 | localizationsDelegates: const [ 27 | GlobalMaterialLocalizations.delegate, 28 | GlobalWidgetsLocalizations.delegate, 29 | ], 30 | supportedLocales: const [ 31 | Locale('zh', 'CH'), 32 | Locale('en', 'US'), 33 | ], 34 | theme: ThemeData( 35 | primarySwatch: Colors.grey, 36 | ), 37 | home: Login(), 38 | builder: EasyLoading.init(), 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /server/GeekMeeting/services/database/query.sql: -------------------------------------------------------------------------------- 1 | -- name: CreateUser :execresult 2 | INSERT INTO users (`email`) VALUES (?); 3 | 4 | -- name: FindUserByEmail :one 5 | SELECT Id FROM users WHERE `email` = ?; 6 | 7 | -- name: FindUserById :one 8 | SELECT Id FROM users WHERE `Id` = ?; 9 | 10 | -- name: FindUserMeetRooms :many 11 | SELECT room.`Id`,room.`start_time` FROM room LEFT JOIN users ON users.`Id` = room.`master_id` WHERE users.`Id` = ?; 12 | 13 | -- name: SelectRoomInfoByNo :one 14 | SELECT Id,start_time,end_time FROM room WHERE `Id` = ?; 15 | 16 | -- name: SelectRoomInfoByIdAndPassword :one 17 | SELECT Id,start_time,end_time,master_id,expand FROM room WHERE `Id` = ? AND `password` = ?; 18 | 19 | -- name: SelectRecondByUserId :many 20 | SELECT `Id`,`start_time`,`end_time` FROM `room` WHERE `master_id` = ? ORDER BY `Id` DESC LIMIT ?,?; 21 | 22 | -- name: SelectRecondByRoomIdAndUserId :one 23 | SELECT recond.`room_id`,recond.`name` FROM recond LEFT JOIN room ON room.`Id` = recond.`room_id` WHERE room.`Id` = ? AND recond.`user_id` = ?; 24 | 25 | -- name: CreateRoom :execresult 26 | INSERT INTO room (`start_time`,`end_time`,`password`,`master_id`) VALUES (?,?,?,?); 27 | 28 | -- name: InsertRecond :exec 29 | INSERT INTO recond (`user_id`,`name`,`room_id`) VALUES (?,?,?); 30 | 31 | -- name: RoomInDayCount :one 32 | SELECT count(Id) FROM room WHERE `master_id` = ? AND `create_time` BETWEEN ? AND ?; -------------------------------------------------------------------------------- /server/GeekMeeting/internal/util/mail.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "GeekMeeting/internal/config" 5 | itypes "GeekMeeting/internal/type" 6 | "context" 7 | "log" 8 | "regexp" 9 | "sync" 10 | 11 | "gopkg.in/gomail.v2" 12 | ) 13 | 14 | const reEmail = `[\w\.]+@\w+\.[a-z]{2,3}(\.[a-z]{2,3})?` 15 | 16 | var ( 17 | regx = regexp.MustCompile(reEmail) 18 | mail *gomail.Dialer 19 | mailOnce sync.Once 20 | conf config.TomlMap 21 | ) 22 | 23 | func SendEmailTo(ctx context.Context, subject, to, content string) error { 24 | mailOnce.Do(func() { 25 | conf = ctx.Value(itypes.ConfigKey).(config.TomlMap) 26 | // log.Println(conf.Mail.Server, conf.Mail.Port, conf.Mail.Username, conf.Mail.Password) 27 | mail = gomail.NewDialer(conf.Mail.Server, conf.Mail.Port, conf.Mail.Username, conf.Mail.Password) 28 | }) 29 | sender, err := mail.Dial() 30 | if err != nil { 31 | log.Printf("Could not send email to %q: %v", to, err) 32 | return err 33 | } 34 | m := gomail.NewMessage() 35 | m.SetHeader("From", conf.Mail.Name) 36 | m.SetAddressHeader("To", to, to) 37 | m.SetHeader("Subject", subject) 38 | m.SetBody("text/html", content) 39 | 40 | if err := gomail.Send(sender, m); err != nil { 41 | log.Printf("Could not send email to %q: %v", to, err) 42 | return err 43 | } 44 | return nil 45 | } 46 | 47 | func VerifyMail(mail string) bool { 48 | return regx.MatchString(mail) 49 | } 50 | -------------------------------------------------------------------------------- /server/GeekMeeting/core/sfu/rooms/signal.go: -------------------------------------------------------------------------------- 1 | package rooms 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/gorilla/websocket" 7 | "github.com/pion/webrtc/v3" 8 | ) 9 | 10 | type websocketMessage struct { 11 | Event string `json:"event"` 12 | Data string `json:"data"` 13 | } 14 | 15 | // Helper to make Gorilla Websockets threadsafe 16 | type threadSafeWriter struct { 17 | *websocket.Conn 18 | sync.Mutex 19 | roomId int64 20 | uid int64 21 | name string 22 | streamId string 23 | peerConnection *webrtc.PeerConnection 24 | } 25 | 26 | func NewSfuConn(conn *websocket.Conn, roomId, uid int64, name, streamId string, peer *webrtc.PeerConnection) *threadSafeWriter { 27 | return &threadSafeWriter{conn, sync.Mutex{}, roomId, uid, name, streamId, peer} 28 | } 29 | 30 | func (t *threadSafeWriter) SetStreamId(streamId string) { 31 | t.streamId = streamId 32 | } 33 | 34 | func (t *threadSafeWriter) WriteJSON(v interface{}) error { 35 | t.Lock() 36 | defer t.Unlock() 37 | 38 | return t.Conn.WriteJSON(v) 39 | } 40 | 41 | func (t *threadSafeWriter) JoinRoom() *room { 42 | return getRooms().joinRoom(t.roomId, t) 43 | } 44 | 45 | // func (t *threadSafeWriter) CheckRoomTime() bool { 46 | // return getRooms().checkRoomTime(t.roomId) 47 | // } 48 | 49 | func (t *threadSafeWriter) LeaveRoom() { 50 | t.Conn.Close() 51 | // t.exit <- struct{}{} 52 | getRooms().leaveRoom(t.roomId, t) 53 | } 54 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/models/create_meeting.dart: -------------------------------------------------------------------------------- 1 | import 'package:get/get.dart'; 2 | 3 | class CreateMeetingModel extends GetxController { 4 | // 开启loading 5 | bool showLoading = false; 6 | // 是否为预约会议 7 | bool _reserved = false; 8 | // 是否需要密码 9 | bool usePassword = false; 10 | DateTime? _startTime; 11 | DateTime? _endTime; 12 | 13 | bool get reserved => _reserved; 14 | set reserved(bool r) { 15 | _reserved = r; 16 | update(); 17 | } 18 | 19 | DateTime get startTime { 20 | DateTime time = _startTime ?? DateTime.now(); 21 | return time; 22 | } 23 | 24 | set startTime(DateTime date) { 25 | _startTime = date; 26 | update(); 27 | _endTime = date.add(const Duration(minutes: 15)); 28 | } 29 | 30 | DateTime get endTime { 31 | DateTime time = _endTime ?? 32 | _startTime?.add(const Duration(minutes: 15)) ?? 33 | DateTime.now().add(const Duration(minutes: 15)); 34 | return time; 35 | } 36 | 37 | set endTime(DateTime date) { 38 | _endTime = date; 39 | update(); 40 | } 41 | } 42 | 43 | // 扩展方法 - 将时间转换为后端需要的格式 44 | extension ToNetParse on DateTime { 45 | String formatNet() { 46 | DateTime date = this; 47 | return "${date.year.toString()}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}:${date.second.toString().padLeft(2, '0')}"; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /server/GeekMeeting/internal/type/ierror.go: -------------------------------------------------------------------------------- 1 | package itypes 2 | 3 | type StatusCode int 4 | 5 | var ( 6 | NoError = NewError(200, "ok") 7 | EmailErr = NewError(4016, "邮箱格式错误") 8 | LimitErr = NewError(4017, "操作频繁") 9 | MarshalErr = NewError(5011, "数据序列化错误") 10 | UnKnowError = NewError(5010, "") 11 | SendEmaiError = NewError(5011, "发送邮件失败") 12 | InternalError = NewError(5101, "内部错误,请联系开发者") 13 | VerifyFormErr = NewError(5012, "验证码错误") 14 | VerificationFailed = NewError(5013, "验证不通过") 15 | CreateUserErr = NewError(5014, "创建用户失败") 16 | ReLoginUser = NewError(5015, "服务端错误,请重新登陆") 17 | TimerErr = NewError(5016, "时间参数错误") 18 | CreateRoomErr = NewError(5017, "创建房间错误") 19 | RoomIdErr = NewError(5018, "房间号或密码错误") 20 | MeetingDidNotStart = NewError(5019, "会议未开始(可提前五分钟入场)") 21 | MeetingEnded = NewError(5020, "会议已结束") 22 | CacheRoomInfoErr = NewError(5021, "加入房间失败") 23 | ) 24 | 25 | type myError struct { 26 | code int 27 | info string 28 | } 29 | 30 | func (e myError) Error() string { 31 | 32 | return e.info 33 | } 34 | 35 | func NewError(code int, info string) myError { 36 | return myError{ 37 | code, 38 | info, 39 | } 40 | } 41 | 42 | func ErrIntoMyError(e error) myError { 43 | return myError{ 44 | UnKnowError.code, 45 | e.Error(), 46 | } 47 | } 48 | 49 | func (e myError) ToResp() resp { 50 | return resp{ 51 | Code: e.code, 52 | Msg: e.info, 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /server/GeekMeeting/meet.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "GeekMeeting/api/router" 5 | "GeekMeeting/internal/config" 6 | itypes "GeekMeeting/internal/type" 7 | "GeekMeeting/internal/util" 8 | "GeekMeeting/services/cache/redis" 9 | meeting "GeekMeeting/services/database/mysql" 10 | "context" 11 | "flag" 12 | "log" 13 | "net/http" 14 | _ "net/http/pprof" 15 | 16 | _ "github.com/go-sql-driver/mysql" 17 | "github.com/julienschmidt/httprouter" 18 | "github.com/rs/cors" 19 | ) 20 | 21 | func main() { 22 | var configPath string 23 | flag.StringVar(&configPath, "config", "./config.toml", "自定义配置文件") 24 | flag.Parse() 25 | log.SetFlags(log.Lshortfile | log.Ldate | log.Ltime) 26 | ctx := context.Background() 27 | conf := config.GetConf(configPath) 28 | ctx = context.WithValue(ctx, itypes.ConfigKey, conf) 29 | 30 | db, err := meeting.GetMysql(ctx) 31 | if err != nil { 32 | panic(err) 33 | } 34 | defer db.Close() 35 | cacheDb := redis.GetRedis(ctx) 36 | jwt := util.NewJwt(util.StringToBytes(conf.Server.Web.JwtKey)) 37 | ctx = context.WithValue(ctx, itypes.JwtKey, jwt) 38 | ctx = context.WithValue(ctx, itypes.DatabasesKey, db) 39 | ctx = context.WithValue(ctx, itypes.CacheKey, cacheDb) 40 | r := httprouter.New() 41 | router.InitRouter(r, ctx) 42 | // handler := cors.Default().Handler(r) 43 | handler := cors.AllowAll().Handler(r) 44 | log.Printf("server run on %s\r\n", conf.Server.Web.Addr) 45 | if err := http.ListenAndServe(conf.Server.Web.Addr, handler); err != nil { 46 | log.Fatal(err) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /client/geek_meeting/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at 17 | # https://dart-lang.github.io/linter/lints/index.html. 18 | # 19 | # Instead of disabling a lint rule for the entire project in the 20 | # section below, it can also be suppressed for a single line of code 21 | # or a specific dart file by using the `// ignore: name_of_lint` and 22 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 23 | # producing the lint. 24 | rules: 25 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 26 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 27 | 28 | # Additional information about this file can be found at 29 | # https://dart.dev/guides/language/analysis-options 30 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/utils/audio.dart: -------------------------------------------------------------------------------- 1 | // import 'dart:html'; 2 | // import 'dart:math'; 3 | // import 'dart:async'; 4 | 5 | // class AudioMaker { 6 | // List urls; 7 | // AudioContext context; 8 | // List buffers; 9 | 10 | // Random random; 11 | 12 | // AudioMaker() { 13 | // this.urls = []; 14 | // this.context = AudioContext(); 15 | // this.buffers = []; 16 | // this.random = Random(0); 17 | // } 18 | 19 | // void checkAndStart() { 20 | // if (buffers.length == urls.length) { 21 | // Timer timer = Timer.repeating(500, this.startAudio); 22 | // } 23 | // } 24 | 25 | // void startAudio(Timer timer) { 26 | // int index = random.nextInt(this.buffers.length); 27 | // print("Audio played [${index}]."); 28 | // AudioBufferSourceNode source = context.createBufferSource(); 29 | // source.buffer = this.buffers[index]; 30 | // source.connect(context.destination, 0, 0); 31 | // source.start(0); 32 | // } 33 | 34 | // void _decodeAudio(url) { 35 | // HttpRequest hr = new HttpRequest.get(url, (req) { 36 | // this.context.decodeAudioData(req.response, (audio_buff) { 37 | // print("${url} decoded."); 38 | // this.buffers.add(audio_buff); 39 | // checkAndStart(); 40 | // }, (evt) { 41 | // print("Error"); 42 | // }); 43 | // }); 44 | // hr.responseType = "arraybuffer"; 45 | // } 46 | 47 | // void loadAndStart() { 48 | // for (String url in this.urls) { 49 | // this._decodeAudio(url); 50 | // } 51 | // } 52 | // } 53 | -------------------------------------------------------------------------------- /server/GeekMeeting/api/handler/meeting/record.go: -------------------------------------------------------------------------------- 1 | package meeting 2 | 3 | import ( 4 | itypes "GeekMeeting/internal/type" 5 | meetingDb "GeekMeeting/services/database/mysql/meeting" 6 | "context" 7 | "database/sql" 8 | "log" 9 | "net/http" 10 | "strconv" 11 | 12 | "github.com/julienschmidt/httprouter" 13 | ) 14 | 15 | // 用户房间记录查询 16 | func MeetingRecord(ctx context.Context) httprouter.Handle { 17 | db := ctx.Value(itypes.DatabasesKey).(*sql.DB) 18 | // local, _ := time.LoadLocation("Asia/Shanghai") 19 | roomDb := meetingDb.New(db) 20 | return func(rw http.ResponseWriter, r *http.Request, p httprouter.Params) { 21 | userId := ctx.Value(itypes.UserId).(int64) 22 | pageStr := r.URL.Query().Get("page") 23 | page, err := strconv.Atoi(pageStr) 24 | if err != nil || page <= 0 { 25 | page = 1 26 | } 27 | // 查询用户房间记录 28 | var pageSize int32 = 10 29 | var offset int32 = (int32(page) - 1) * 10 30 | rooms, err := roomDb.SelectRecondByUserId(ctx, meetingDb.SelectRecondByUserIdParams{ 31 | MasterID: userId, 32 | Offset: offset, 33 | Limit: pageSize, 34 | }) 35 | if err != nil && err != sql.ErrNoRows { 36 | resp, err := itypes.UnKnowError.ToResp().ToJson() 37 | if err != nil { 38 | log.Println(err) 39 | rw.WriteHeader(http.StatusBadGateway) 40 | return 41 | } 42 | _, _ = rw.Write(resp) 43 | return 44 | } 45 | // 返回用户房间记录 46 | rsp := itypes.NoError.ToResp() 47 | rsp.Data = rooms 48 | resp, err := rsp.ToJson() 49 | if err != nil { 50 | log.Println(err) 51 | rw.WriteHeader(http.StatusBadGateway) 52 | return 53 | } 54 | _, _ = rw.Write(resp) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /server/GeekMeeting/services/database/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `users` ( 2 | `Id` bigint(20) NOT NULL AUTO_INCREMENT, 3 | `email` varchar(50) NOT NULL DEFAULT '' COMMENT '邮箱', 4 | `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | PRIMARY KEY (`Id`), 6 | UNIQUE KEY `mail` (`email`) 7 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 8 | 9 | CREATE TABLE `room` ( 10 | `Id` bigint(20) NOT NULL AUTO_INCREMENT, 11 | `start_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', 12 | `end_time` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00', 13 | `password` varchar(32) NOT NULL DEFAULT '' COMMENT '会议密码', 14 | `master_id` bigint(20) NOT NULL DEFAULT '0', 15 | `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 16 | `expand` varchar(1) NOT NULL DEFAULT '' COMMENT '不做任何功能,用于查询结果添加字段', 17 | PRIMARY KEY (`Id`), 18 | KEY `master_index` (`master_id`), 19 | CONSTRAINT `room_ibfk_1` FOREIGN KEY (`master_id`) REFERENCES `users` (`Id`) ON DELETE NO ACTION ON UPDATE NO ACTION 20 | ) ENGINE=InnoDB AUTO_INCREMENT=1000 DEFAULT CHARSET=utf8 COMMENT='会议房间'; 21 | 22 | 23 | CREATE TABLE `recond` ( 24 | `Id` bigint(20) NOT NULL AUTO_INCREMENT, 25 | `user_id` bigint(20) NOT NULL DEFAULT '0', 26 | `name` varchar(50) DEFAULT NULL COMMENT '会议昵称', 27 | `room_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '会议ID', 28 | PRIMARY KEY (`Id`), 29 | KEY `user_id` (`user_id`), 30 | KEY `recond_ibfk_2` (`room_id`), 31 | CONSTRAINT `recond_ibfk_2` FOREIGN KEY (`room_id`) REFERENCES `room` (`Id`) ON DELETE NO ACTION ON UPDATE NO ACTION, 32 | CONSTRAINT `recond_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`Id`) ON DELETE NO ACTION ON UPDATE NO ACTION 33 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='参会记录'; 34 | -------------------------------------------------------------------------------- /server/GeekMeeting/services/cache/redis/redis.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "GeekMeeting/internal/config" 5 | itypes "GeekMeeting/internal/type" 6 | "GeekMeeting/services/cache" 7 | "context" 8 | "sync" 9 | "time" 10 | 11 | "github.com/go-redis/redis/v8" 12 | ) 13 | 14 | type redisWrap struct { 15 | client *redis.Client 16 | } 17 | 18 | var ( 19 | once sync.Once 20 | cacheDb redisWrap 21 | ) 22 | 23 | func GetRedis(ctx context.Context) cache.Cache { 24 | once.Do(func() { 25 | conf := ctx.Value(itypes.ConfigKey).(config.TomlMap) 26 | cacheDb = redisWrap{ 27 | redis.NewClient(&redis.Options{ 28 | Addr: conf.SQL.Cache.Addr, 29 | Username: conf.SQL.Cache.Username, 30 | Password: conf.SQL.Cache.Password, 31 | DialTimeout: time.Second * 3, 32 | }), 33 | } 34 | }) 35 | return cacheDb 36 | } 37 | 38 | func (c redisWrap) Set(ctx context.Context, key string, value interface{}) error { 39 | return c.client.Set(ctx, key, value, 0).Err() 40 | } 41 | 42 | func (c redisWrap) Get(ctx context.Context, key string) ([]byte, error) { 43 | return c.client.Get(ctx, key).Bytes() 44 | } 45 | 46 | func (c redisWrap) SetEx(ctx context.Context, key string, value interface{}, expiration time.Duration) error { 47 | return c.client.SetEX(ctx, key, value, expiration).Err() 48 | } 49 | 50 | func (c redisWrap) IsExist(ctx context.Context, key string) bool { 51 | var flag bool 52 | if result := c.client.Exists(ctx, key); result.Err() == nil { 53 | // log.Println(result.Val()) 54 | if result.Val() > 0 { 55 | flag = true 56 | } 57 | } 58 | return flag 59 | } 60 | 61 | func (c redisWrap) DelKey(ctx context.Context, key string) error { 62 | return c.client.Del(ctx, key).Err() 63 | } 64 | -------------------------------------------------------------------------------- /server/GeekMeeting/internal/util/coder.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bytes" 5 | "encoding/gob" 6 | "encoding/json" 7 | "errors" 8 | "reflect" 9 | ) 10 | 11 | type encodeType int 12 | 13 | const ( 14 | Gob encodeType = iota 15 | Json 16 | ) 17 | 18 | var ( 19 | UnknownCode = errors.New("未知的编码类型") 20 | DecodeTypeErr = errors.New("解码目标参数需传入指针类型") 21 | ) 22 | 23 | type coder struct { 24 | encodeType encodeType 25 | } 26 | 27 | func NewCoding(encodeType encodeType) coder { 28 | return coder{encodeType: encodeType} 29 | } 30 | 31 | func (c coder) Encode(data interface{}) ([]byte, error) { 32 | if c.encodeType == Gob { 33 | return c.gobEncoder(data) 34 | } else if c.encodeType == Json { 35 | return c.jsonEncoder(data) 36 | } 37 | return nil, UnknownCode 38 | } 39 | 40 | func (c coder) Decode(data []byte, target interface{}) error { 41 | targetValue := reflect.ValueOf(target) 42 | if targetValue.Kind() != reflect.Ptr { 43 | return DecodeTypeErr 44 | } 45 | if c.encodeType == Gob { 46 | return c.gobDecoder(data, target) 47 | } else if c.encodeType == Json { 48 | return c.jsonDecoder(data, target) 49 | } 50 | return UnknownCode 51 | } 52 | 53 | func (c coder) jsonEncoder(data interface{}) ([]byte, error) { 54 | return json.Marshal(data) 55 | } 56 | 57 | func (c coder) gobEncoder(data interface{}) ([]byte, error) { 58 | var buf bytes.Buffer 59 | encoder := gob.NewEncoder(&buf) 60 | err := encoder.Encode(data) 61 | return buf.Bytes(), err 62 | } 63 | 64 | func (c coder) jsonDecoder(data []byte, target interface{}) error { 65 | return json.Unmarshal(data, target) 66 | } 67 | 68 | func (c coder) gobDecoder(data []byte, target interface{}) error { 69 | return gob.NewDecoder(bytes.NewReader(data)).Decode(target) 70 | } 71 | -------------------------------------------------------------------------------- /server/GeekMeeting/go.mod: -------------------------------------------------------------------------------- 1 | module GeekMeeting 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/BurntSushi/toml v0.4.1 7 | github.com/go-redis/redis/v8 v8.11.4 8 | github.com/go-sql-driver/mysql v1.6.0 9 | github.com/golang-jwt/jwt v3.2.2+incompatible 10 | github.com/gorilla/websocket v1.4.2 11 | github.com/julienschmidt/httprouter v1.3.0 12 | github.com/pion/rtcp v1.2.9 13 | github.com/pion/webrtc/v3 v3.1.11 14 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df 15 | ) 16 | 17 | require ( 18 | github.com/cespare/xxhash/v2 v2.1.2 // indirect 19 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 20 | github.com/google/uuid v1.3.0 // indirect 21 | github.com/pion/datachannel v1.5.2 // indirect 22 | github.com/pion/dtls/v2 v2.0.10 // indirect 23 | github.com/pion/ice/v2 v2.1.14 // indirect 24 | github.com/pion/interceptor v0.1.2 // indirect 25 | github.com/pion/logging v0.2.2 // indirect 26 | github.com/pion/mdns v0.0.5 // indirect 27 | github.com/pion/randutil v0.1.0 // indirect 28 | github.com/pion/rtp v1.7.4 // indirect 29 | github.com/pion/sctp v1.8.0 // indirect 30 | github.com/pion/sdp/v3 v3.0.4 // indirect 31 | github.com/pion/srtp/v2 v2.0.5 // indirect 32 | github.com/pion/stun v0.3.5 // indirect 33 | github.com/pion/transport v0.12.3 // indirect 34 | github.com/pion/turn/v2 v2.0.5 // indirect 35 | github.com/pion/udp v0.1.1 // indirect 36 | github.com/rs/cors v1.8.2 37 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect 38 | golang.org/x/net v0.0.0-20211020060615-d418f374d309 // indirect 39 | golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359 // indirect 40 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect 41 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 42 | ) 43 | -------------------------------------------------------------------------------- /server/GeekMeeting/api/handler/login/send_code.go: -------------------------------------------------------------------------------- 1 | package login 2 | 3 | import ( 4 | my_types "GeekMeeting/internal/type" 5 | "GeekMeeting/internal/util" 6 | "GeekMeeting/services/cache" 7 | "context" 8 | "fmt" 9 | "log" 10 | "math/rand" 11 | "net/http" 12 | "strings" 13 | "time" 14 | 15 | "github.com/julienschmidt/httprouter" 16 | ) 17 | 18 | // 发送验证码 19 | func SendCode(ctx context.Context) httprouter.Handle { 20 | // 前置检查 21 | rand.Seed(time.Now().UnixMicro()) 22 | min := 1000 23 | max := 9999 24 | cache := ctx.Value(my_types.CacheKey).(cache.Cache) 25 | return func(rw http.ResponseWriter, r *http.Request, _ httprouter.Params) { 26 | clientIp := strings.Split(r.RemoteAddr, ":")[0] 27 | emil := r.PostFormValue("email") 28 | // 验证邮箱地址 29 | if !util.VerifyMail(emil) { 30 | resp, err := my_types.EmailErr.ToResp().ToJson() 31 | if err != nil { 32 | log.Println(err) 33 | rw.WriteHeader(http.StatusBadGateway) 34 | return 35 | } 36 | _, _ = rw.Write(resp) 37 | return 38 | } 39 | // 检测时候发送过于频繁 40 | if cache.IsExist(ctx, clientIp) || cache.IsExist(ctx, emil) { 41 | resp, err := my_types.LimitErr.ToResp().ToJson() 42 | if err != nil { 43 | log.Println(err) 44 | rw.WriteHeader(http.StatusBadGateway) 45 | return 46 | } 47 | _, _ = rw.Write(resp) 48 | return 49 | } 50 | 51 | if err := cache.SetEx(ctx, clientIp, emil, time.Minute*2); err != nil { 52 | resp, err := my_types.InternalError.ToResp().ToJson() 53 | if err != nil { 54 | log.Println(err) 55 | rw.WriteHeader(http.StatusBadGateway) 56 | return 57 | } 58 | _, _ = rw.Write(resp) 59 | return 60 | } 61 | 62 | vCode := rand.Intn(max-min) + min 63 | // 发送邮件 64 | if err := util.SendEmailTo(ctx, "GeekMeeting #", emil, fmt.Sprintf("您的验证码为:%d", vCode)); err != nil { 65 | resp, err := my_types.SendEmaiError.ToResp().ToJson() 66 | if err != nil { 67 | log.Println(err) 68 | rw.WriteHeader(http.StatusBadGateway) 69 | return 70 | } 71 | _, _ = rw.Write(resp) 72 | return 73 | } 74 | 75 | if err := cache.SetEx(ctx, emil, vCode, time.Minute*2); err != nil { 76 | resp, err := my_types.ErrIntoMyError(err).ToResp().ToJson() 77 | if err != nil { 78 | log.Println(err) 79 | rw.WriteHeader(http.StatusBadGateway) 80 | return 81 | } 82 | _, _ = rw.Write(resp) 83 | return 84 | } 85 | resp, err := my_types.NoError.ToResp().ToJson() 86 | if err != nil { 87 | log.Println(err) 88 | rw.WriteHeader(http.StatusBadGateway) 89 | return 90 | } 91 | _, _ = rw.Write(resp) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /server/GeekMeeting/internal/util/jwt.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "GeekMeeting/services/auth" 5 | "errors" 6 | "sync" 7 | "time" 8 | 9 | "github.com/golang-jwt/jwt" 10 | ) 11 | 12 | type tokenType uint 13 | 14 | var ( 15 | jwtOnce sync.Once 16 | tool *jwtTool 17 | access tokenType = 0 18 | refresh tokenType = 1 19 | ) 20 | 21 | type myClaims struct { 22 | Uid int64 `json:"uid"` 23 | Type tokenType `json:"type"` 24 | Rtsign string `json:"rt_sign"` 25 | jwt.StandardClaims 26 | } 27 | 28 | type jwtTool struct { 29 | key []byte 30 | } 31 | 32 | func NewJwt(key ...[]byte) auth.JwtTool { 33 | jwtOnce.Do(func() { 34 | tool = &jwtTool{} 35 | tool.SetKey(key[0]) 36 | }) 37 | return tool 38 | } 39 | 40 | func (j *jwtTool) SetKey(key []byte) { 41 | j.key = key 42 | } 43 | 44 | func (j *jwtTool) GetKey() []byte { 45 | return j.key 46 | } 47 | 48 | func (j *jwtTool) NewToken(userId int64) (accessToken, refreshToken string, err error) { 49 | accessToken, err = newToken(myClaims{ 50 | userId, 51 | access, 52 | "", 53 | jwt.StandardClaims{ 54 | ExpiresAt: time.Now().Add(time.Minute * 10).Unix(), 55 | }, 56 | }, j.GetKey()) 57 | if err != nil { 58 | return "", "", err 59 | } 60 | refreshToken, err = newToken(myClaims{ 61 | userId, 62 | refresh, 63 | Md5(accessToken), 64 | jwt.StandardClaims{ 65 | ExpiresAt: time.Now().Add(time.Hour * 3 * 24).Unix(), 66 | }, 67 | }, j.GetKey()) 68 | if err != nil { 69 | return "", "", err 70 | } 71 | return 72 | } 73 | 74 | func (j *jwtTool) ParseToken(tokenStr string) (int64, bool, error) { 75 | var c = myClaims{} 76 | token, err := jwt.ParseWithClaims(tokenStr, &c, func(t *jwt.Token) (interface{}, error) { 77 | return j.GetKey(), nil 78 | }) 79 | if err != nil || c.Type != access { 80 | return c.Uid, false, err 81 | } 82 | return c.Uid, token.Valid, err 83 | } 84 | 85 | func (j *jwtTool) RefreshToken(tokenStr, oldToken string) (accessToken, refreshToken string, err error) { 86 | var c = myClaims{} 87 | // 检查被刷新的token 88 | auid, astate, err := j.ParseToken(oldToken) 89 | if err == nil || astate { 90 | return "", "", errors.New("Invalid token 1") 91 | } 92 | token, err := jwt.ParseWithClaims(tokenStr, &c, func(t *jwt.Token) (interface{}, error) { 93 | return j.GetKey(), nil 94 | }) 95 | if err != nil || c.Type != refresh || c.Uid != auid || c.Rtsign != Md5(oldToken) || !token.Valid { 96 | return "", "", errors.New("Invalid token 2") 97 | } 98 | return j.NewToken(c.Uid) 99 | } 100 | 101 | func newToken(c myClaims, key []byte) (string, error) { 102 | nToken := jwt.NewWithClaims(jwt.SigningMethodHS256, c) 103 | return nToken.SignedString(key) 104 | } 105 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/components/meeting_list.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_webrtc/flutter_webrtc.dart'; 4 | import 'package:geek_meeting/models/metting/meeting_user.dart'; 5 | import 'package:get/get_state_manager/src/simple/get_state.dart'; 6 | 7 | Widget meetingItem(MeetingUser e, Function setting) { 8 | debugPrint( 9 | "display:${e.display},userId:${e.id},stream not null:${e.stream != null},display:${e.display},,microphone:${e.microphone}"); 10 | return Container( 11 | width: 300, 12 | height: 260, 13 | child: GetBuilder( 14 | init: e, 15 | builder: (_) { 16 | return Column( 17 | children: [ 18 | Expanded( 19 | flex: 5, 20 | child: Center( 21 | child: e.display && e.stream != null 22 | ? RTCVideoView(e.videoRenderer) 23 | : Text( 24 | e.name.split("").last, 25 | style: const TextStyle(fontSize: 50), 26 | ), 27 | ), 28 | ), 29 | Expanded( 30 | child: Container( 31 | color: Colors.black87, 32 | child: Row( 33 | mainAxisAlignment: MainAxisAlignment.spaceAround, 34 | children: [ 35 | Icon( 36 | Icons.volume_up_rounded, 37 | color: e.microphone ? Colors.green : Colors.grey, 38 | ), 39 | InkWell( 40 | child: Icon( 41 | e.microphone ? Icons.mic_none : Icons.mic_off, 42 | color: e.microphone ? Colors.blue : Colors.red, 43 | ), 44 | onTap: e.isSelf 45 | ? () { 46 | e.microphone = !e.microphone; 47 | } 48 | : null, 49 | ), 50 | InkWell( 51 | child: Icon( 52 | Icons.screen_share_outlined, 53 | color: e.display ? Colors.blue : Colors.grey, 54 | ), 55 | onTap: e.isSelf 56 | ? () { 57 | e.display = !e.display; 58 | } 59 | : null, 60 | ), 61 | InkWell( 62 | child: Icon( 63 | Icons.settings, 64 | color: e.isSelf ? Colors.white : Colors.grey, 65 | ), 66 | onTap: e.isSelf ? () => setting() : null, 67 | ) 68 | ], 69 | ), 70 | ), 71 | ), 72 | ], 73 | ); 74 | }, 75 | ), 76 | decoration: e.isSelf 77 | ? const BoxDecoration( 78 | color: Colors.blueAccent, 79 | boxShadow: [ 80 | BoxShadow( 81 | color: Colors.blueAccent, 82 | // offset: Offset(3.0, 6.0), 83 | spreadRadius: 1, 84 | blurRadius: 5, 85 | ), 86 | ], 87 | ) 88 | : const BoxDecoration(color: Colors.blueAccent), 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /server/GeekMeeting/core/sfu/rooms/rooms.go: -------------------------------------------------------------------------------- 1 | package rooms 2 | 3 | import ( 4 | "log" 5 | "sync" 6 | "time" 7 | 8 | "github.com/pion/webrtc/v3" 9 | ) 10 | 11 | var ( 12 | roomsOnce sync.Once 13 | rms *rooms 14 | ) 15 | 16 | type rooms struct { 17 | sync.Locker 18 | rs map[int64]*room 19 | } 20 | 21 | func GetNumber(roomId int64) (count int) { 22 | if _, ok := getRooms().rs[roomId]; ok { 23 | count = len(getRooms().rs[roomId].peerConnections) 24 | } 25 | return 26 | } 27 | 28 | func getRooms() *rooms { 29 | roomsOnce.Do(func() { 30 | rms = &rooms{&sync.Mutex{}, map[int64]*room{}} 31 | // 无效房间自动回收 32 | go rms.free() 33 | }) 34 | return rms 35 | } 36 | 37 | // func (r *rooms) checkRoomTime(roomId int64) bool { 38 | // _, result := r.rs[roomId] 39 | // if result { 40 | // result = r.rs[roomId].exitTime.IsZero() 41 | // } 42 | // return result 43 | // } 44 | 45 | func (r *rooms) joinRoom(roomId int64, user *threadSafeWriter) (roomPtr *room) { 46 | var ok bool 47 | if roomPtr, ok = r.rs[roomId]; !ok { 48 | r.Lock() 49 | defer r.Unlock() 50 | roomPtr = &room{trackLocals: make(map[string]*webrtc.TrackLocalStaticRTP), free: make(chan struct{})} 51 | roomPtr.init() 52 | r.rs[roomId] = roomPtr 53 | // 定时发送关键帧 54 | // 房间到期关闭 55 | // go func() { 56 | // timer := time.NewTimer(time.Second * 30) 57 | // for { 58 | // select { 59 | // case <-timer.C: 60 | // if time.Now().After(endTIime) { 61 | // r.closeRoom(roomId) 62 | // return 63 | // } 64 | // } 65 | // } 66 | // }() 67 | } 68 | roomPtr.userJoin(user) 69 | // log.Printf("%#v", user) 70 | // log.Println(r.rs[roomId]) 71 | // log.Println(roomPtr) 72 | return roomPtr 73 | } 74 | 75 | // 关闭房间 76 | // func (r *rooms) closeRoom(roomId int64) { 77 | // if roomPtr, ok := r.rs[roomId]; ok { 78 | // roomPtr.RLock() 79 | // for index := range roomPtr.peerConnections { 80 | // _ = roomPtr.peerConnections[index].user.Close() 81 | // } 82 | // roomPtr.RUnlock() 83 | // roomPtr.free <- struct{}{} 84 | // r.Lock() 85 | // delete(r.rs, roomId) 86 | // r.Unlock() 87 | // } 88 | // } 89 | 90 | // 用户离开房间 91 | func (r *rooms) leaveRoom(roomId int64, user *threadSafeWriter) { 92 | if roomPtr, ok := r.rs[roomId]; ok { 93 | roomPtr.Lock() 94 | defer roomPtr.Unlock() 95 | for index := range roomPtr.peerConnections { 96 | if roomPtr.peerConnections[index].user == user { 97 | roomPtr.peerConnections = append(roomPtr.peerConnections[0:index], roomPtr.peerConnections[index+1:]...) 98 | // log.Println("leave room:", len(roomPtr.peerConnections)) 99 | if len(roomPtr.peerConnections) == 0 { 100 | r.Lock() 101 | delete(r.rs, roomId) 102 | r.Unlock() 103 | log.Printf("free room %d\r\n", roomId) 104 | } 105 | return 106 | } 107 | } 108 | } 109 | } 110 | 111 | func (r *rooms) free() { 112 | timer := time.NewTicker(time.Minute * 10) 113 | defer timer.Stop() 114 | var waitRemove = make([]int64, 0, 10) 115 | for range timer.C { 116 | freeTime := time.Now().Add(time.Minute * -3) 117 | for index := range r.rs { 118 | // 如果房间已超过资源自身回收周期,则从map中移除 119 | if !r.rs[index].exitTime.IsZero() && r.rs[index].exitTime.Before(freeTime) { 120 | waitRemove = append(waitRemove, index) 121 | } 122 | } 123 | r.Lock() 124 | for index := range waitRemove { 125 | delete(r.rs, waitRemove[index]) 126 | } 127 | r.Unlock() 128 | waitRemove = waitRemove[0:0] 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /server/GeekMeeting/api/handler/login/verify.go: -------------------------------------------------------------------------------- 1 | package login 2 | 3 | import ( 4 | my_types "GeekMeeting/internal/type" 5 | "GeekMeeting/internal/util" 6 | "GeekMeeting/services/auth" 7 | "GeekMeeting/services/cache" 8 | meetingDb "GeekMeeting/services/database/mysql/meeting" 9 | "context" 10 | "database/sql" 11 | "log" 12 | "net/http" 13 | "strings" 14 | 15 | "github.com/julienschmidt/httprouter" 16 | ) 17 | 18 | // 验证校验码 19 | func Verify(ctx context.Context) httprouter.Handle { 20 | cache := ctx.Value(my_types.CacheKey).(cache.Cache) 21 | jwt := ctx.Value(my_types.JwtKey).(auth.JwtTool) 22 | db := ctx.Value(my_types.DatabasesKey).(*sql.DB) 23 | users := meetingDb.New(db) 24 | return func(rw http.ResponseWriter, r *http.Request, p httprouter.Params) { 25 | vCode := r.FormValue("verify_code") 26 | eMail := r.FormValue("email") 27 | clientIp := strings.Split(r.RemoteAddr, ":")[0] 28 | cacheMailByte, err := cache.Get(ctx, clientIp) 29 | cacheMail := util.BytesToString(cacheMailByte) 30 | if vCode == "" || err != nil || cacheMail == "" { 31 | resp, err := my_types.VerifyFormErr.ToResp().ToJson() 32 | if err != nil { 33 | log.Println(err) 34 | rw.WriteHeader(http.StatusBadGateway) 35 | return 36 | } 37 | _, _ = rw.Write(resp) 38 | return 39 | } 40 | // 验证邮箱和验证码是否通过 41 | cacheVerifyCodeByte, err := cache.Get(ctx, cacheMail) 42 | cacheVerifyCode := util.BytesToString(cacheVerifyCodeByte) 43 | if eMail != cacheMail || err != nil || cacheVerifyCode != vCode { 44 | resp, err := my_types.VerificationFailed.ToResp().ToJson() 45 | if err != nil { 46 | log.Println(err) 47 | rw.WriteHeader(http.StatusBadGateway) 48 | return 49 | } 50 | _, _ = rw.Write(resp) 51 | return 52 | } 53 | _ = cache.DelKey(ctx, clientIp) 54 | _ = cache.DelKey(ctx, eMail) 55 | // log.Println(eMail) 56 | // 检查用户是否存在 57 | id, err := users.FindUserByEmail(ctx, eMail) 58 | if err != nil && err != sql.ErrNoRows { 59 | log.Println(err) 60 | resp, err := my_types.InternalError.ToResp().ToJson() 61 | if err != nil { 62 | log.Println(err) 63 | rw.WriteHeader(http.StatusBadGateway) 64 | return 65 | } 66 | _, _ = rw.Write(resp) 67 | return 68 | } 69 | // 用户不存在,创建用户 70 | if id == 0 { 71 | if result, err := users.CreateUser(ctx, eMail); err != nil { 72 | resp, err := my_types.CreateUserErr.ToResp().ToJson() 73 | if err != nil { 74 | log.Println(err) 75 | rw.WriteHeader(http.StatusBadGateway) 76 | return 77 | } 78 | _, _ = rw.Write(resp) 79 | return 80 | } else { 81 | id, err = result.LastInsertId() 82 | if err != nil { 83 | resp, err := my_types.ReLoginUser.ToResp().ToJson() 84 | if err != nil { 85 | log.Println(err) 86 | rw.WriteHeader(http.StatusBadGateway) 87 | return 88 | } 89 | _, _ = rw.Write(resp) 90 | return 91 | } 92 | } 93 | } 94 | // log.Println("create token") 95 | // create token 96 | rsp := my_types.NoError.ToResp() 97 | // rsp.Token.AccessToken 98 | accessToken, refreshToken, err := jwt.NewToken(id) 99 | if err != nil { 100 | log.Println("NewToken:", err) 101 | rw.WriteHeader(http.StatusBadGateway) 102 | return 103 | } 104 | rsp.Token.AccessToken = accessToken 105 | rsp.Token.RefreshToken = refreshToken 106 | resp, err := rsp.ToJson() 107 | if err != nil { 108 | log.Println(err) 109 | rw.WriteHeader(http.StatusBadGateway) 110 | return 111 | } 112 | _, _ = rw.Write(resp) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /server/GeekMeeting/api/middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | itypes "GeekMeeting/internal/type" 5 | "GeekMeeting/internal/util" 6 | "GeekMeeting/services/auth" 7 | "GeekMeeting/services/cache" 8 | "context" 9 | "log" 10 | "net/http" 11 | "strings" 12 | 13 | "github.com/julienschmidt/httprouter" 14 | ) 15 | 16 | func AuthMiddleware(ctx context.Context, next func(context.Context) httprouter.Handle) httprouter.Handle { 17 | jwt := ctx.Value(itypes.JwtKey).(auth.JwtTool) 18 | return func(rw http.ResponseWriter, r *http.Request, p httprouter.Params) { 19 | authorization := r.Header.Get("Authorization") 20 | // log.Println(authorization) 21 | if !strings.HasPrefix(authorization, "Bearer ") { 22 | rw.WriteHeader(http.StatusUnauthorized) 23 | return 24 | } 25 | authorization = authorization[7:] 26 | uid, state, err := jwt.ParseToken(authorization) 27 | if err != nil || !state { 28 | rw.WriteHeader(http.StatusUnauthorized) 29 | return 30 | } 31 | ctx = context.WithValue(ctx, itypes.UserId, uid) 32 | // r = r.WithContext(ctx) 33 | next(ctx)(rw, r, p) 34 | } 35 | } 36 | 37 | func AuthMiddlewareSignal(ctx context.Context, next func(context.Context) httprouter.Handle) httprouter.Handle { 38 | // jwt := ctx.Value(itypes.JwtKey).(auth.JwtTool) 39 | // db := ctx.Value(itypes.DatabasesKey).(*sql.DB) 40 | // roomDb := meetingDb.New(db) 41 | cache := ctx.Value(itypes.CacheKey).(cache.Cache) 42 | return func(rw http.ResponseWriter, r *http.Request, p httprouter.Params) { 43 | key := p.ByName("key") 44 | // uid, state, err := jwt.ParseToken(authorization) 45 | // if err != nil || !state { 46 | // log.Println(err, state) 47 | // rw.WriteHeader(http.StatusUnauthorized) 48 | // return 49 | // } 50 | // roomIdStr := p.ByName("room_id") 51 | // name := p.ByName("name") 52 | // password := p.ByName("password") 53 | // if password != "" { 54 | // password = util.Md5(password) 55 | // } 56 | // roomId, _ := strconv.ParseInt(roomIdStr, 10, 64) 57 | // if roomId <= 0 || name == "" { 58 | // log.Println(err, state) 59 | // rw.WriteHeader(http.StatusBadRequest) 60 | // return 61 | // } 62 | // // 校验会议ID和密码 63 | // roomInfo, err := roomDb.SelectRoomInfoByIdAndPassword(ctx, 64 | // meetingDb.SelectRoomInfoByIdAndPasswordParams{ 65 | // ID: int64(roomId), 66 | // Password: password, 67 | // }, 68 | // ) 69 | // if err != nil { 70 | // resp, err := itypes.RoomIdErr.ToResp().ToJson() 71 | // if err != nil { 72 | // log.Println(err) 73 | // rw.WriteHeader(http.StatusBadGateway) 74 | // return 75 | // } 76 | // _, _ = rw.Write(resp) 77 | // return 78 | // } 79 | // // 校验会议开始和结束时间 80 | // currentTime := time.Now() 81 | // if roomInfo.StartTime.Time.After(currentTime.Add(time.Minute*5)) || roomInfo.EndTime.Time.Before(currentTime) { 82 | // resp, err := itypes.MeetingDidNotStart.ToResp().ToJson() 83 | // if err != nil { 84 | // log.Println(err) 85 | // rw.WriteHeader(http.StatusBadGateway) 86 | // return 87 | // } 88 | // _, _ = rw.Write(resp) 89 | // return 90 | // } 91 | if key == "" { 92 | rw.WriteHeader(http.StatusUnauthorized) 93 | return 94 | } 95 | joinInfoBytes, err := cache.Get(ctx, key) 96 | if err != nil { 97 | log.Println(err) 98 | rw.WriteHeader(http.StatusServiceUnavailable) 99 | } 100 | var joinInfo itypes.JoinInfo 101 | if err := util.NewCoding(util.Gob).Decode(joinInfoBytes, &joinInfo); err != nil { 102 | log.Println(err) 103 | rw.WriteHeader(http.StatusServiceUnavailable) 104 | } 105 | if err := cache.DelKey(ctx, key); err != nil { 106 | log.Println(err) 107 | } 108 | ctx = context.WithValue(ctx, itypes.UserId, joinInfo.UserId) 109 | ctx = context.WithValue(ctx, itypes.RoomsId, joinInfo.RoomId) 110 | ctx = context.WithValue(ctx, itypes.UserName, joinInfo.UserName) 111 | next(ctx)(rw, r, p) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/pages/home.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:geek_meeting/components/dialog/create_room.dart'; 3 | import 'package:geek_meeting/components/dialog/join_room.dart'; 4 | import 'package:geek_meeting/components/dialog/room_recond.dart'; 5 | 6 | class Home extends StatelessWidget { 7 | const Home({Key? key}) : super(key: key); 8 | 9 | @override 10 | Widget build(BuildContext context) { 11 | // if (Navigator.canPop(context)) Navigator.pop(context); 12 | // final TextEditingController _roomId = TextEditingController(); 13 | return Scaffold( 14 | appBar: AppBar( 15 | title: const Text("Geek 云会议系统"), 16 | automaticallyImplyLeading: false, 17 | ), 18 | body: Center( 19 | child: Wrap( 20 | spacing: 50, 21 | runSpacing: 50, 22 | children: [ 23 | SizedBox( 24 | width: 200, 25 | height: 200, 26 | child: ElevatedButton( 27 | onPressed: () => {showMeetingRecond(context)}, 28 | child: Column( 29 | mainAxisAlignment: MainAxisAlignment.center, 30 | children: const [ 31 | Icon( 32 | Icons.person_pin, 33 | size: 60, 34 | ), 35 | Text("我的会议"), 36 | ], 37 | ), 38 | style: ButtonStyle( 39 | backgroundColor: 40 | MaterialStateProperty.all(Colors.blueGrey[200]), 41 | shape: MaterialStateProperty.all( 42 | RoundedRectangleBorder( 43 | borderRadius: BorderRadius.circular(15), 44 | ), 45 | ), //圆角弧度 46 | ), 47 | ), 48 | ), 49 | SizedBox( 50 | width: 200, 51 | height: 200, 52 | child: ElevatedButton( 53 | onPressed: () => { 54 | showCreateRoom(context) 55 | .then((_) => showMeetingRecond(context)) 56 | }, 57 | child: Column( 58 | mainAxisAlignment: MainAxisAlignment.center, 59 | children: const [ 60 | Icon( 61 | Icons.add_comment_rounded, 62 | size: 60, 63 | ), 64 | Text("创建会议"), 65 | ], 66 | ), 67 | style: ButtonStyle( 68 | backgroundColor: 69 | MaterialStateProperty.all(Colors.blueGrey[200]), 70 | shape: MaterialStateProperty.all( 71 | RoundedRectangleBorder( 72 | borderRadius: BorderRadius.circular(15), 73 | ), 74 | ), //圆角弧度 75 | ), 76 | ), 77 | ), 78 | SizedBox( 79 | width: 200, 80 | height: 200, 81 | child: ElevatedButton( 82 | onPressed: () => {showJoinRoom(context)}, 83 | child: Column( 84 | mainAxisAlignment: MainAxisAlignment.center, 85 | children: const [ 86 | Icon( 87 | Icons.meeting_room_outlined, 88 | size: 60, 89 | ), 90 | Text("加入会议"), 91 | ], 92 | ), 93 | style: ButtonStyle( 94 | backgroundColor: 95 | MaterialStateProperty.all(Colors.blueGrey[200]), 96 | shape: MaterialStateProperty.all( 97 | RoundedRectangleBorder( 98 | borderRadius: BorderRadius.circular(15), 99 | ), 100 | ), //圆角弧度 101 | ), 102 | ), 103 | ), 104 | ], 105 | ), 106 | ), 107 | ); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/pages/meeting.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_webrtc/flutter_webrtc.dart'; 3 | import 'package:geek_meeting/components/meeting_list.dart'; 4 | import 'package:geek_meeting/components/setting.dart'; 5 | import 'package:geek_meeting/models/metting/meeting_room.dart'; 6 | import 'package:geek_meeting/route/routes_path.dart'; 7 | import 'package:get/get.dart'; 8 | 9 | class Meeting extends StatelessWidget { 10 | Meeting({Key? key}) : super(key: key); 11 | final String? roomId = Get.parameters["roomId"]; 12 | final String? roomKey = Get.parameters["key"]; 13 | late final MeetingRoom? room; 14 | late final MediaStream? stream; 15 | @override 16 | Widget build(BuildContext context) { 17 | // NetUtil.net. 18 | try { 19 | if (roomKey == null || roomId == null) { 20 | Future.delayed(const Duration(milliseconds: 100), 21 | () => {Get.offNamed(RoutesPath.Home)}); 22 | } 23 | } catch (e) { 24 | debugPrint(e.toString()); 25 | } 26 | void _setting() { 27 | devices(context).then((setting) { 28 | if (setting.localRenderer.srcObject == null) { 29 | return; 30 | } 31 | setting.gcRender = true; 32 | stream = setting.localRenderer.srcObject; 33 | room!.initSelfStream(stream); 34 | room!.audioOutput = setting.audioDevices; 35 | Future.delayed(const Duration(seconds: 1), () => setting.dispose()); 36 | }); 37 | } 38 | 39 | void _switchStream() { 40 | devices(context).then((setting) { 41 | if (setting.localRenderer.srcObject == null) { 42 | return; 43 | } 44 | setting.gcRender = true; 45 | stream = setting.localRenderer.srcObject; 46 | room!.switchSelfStream(stream); 47 | room!.audioOutput = setting.audioDevices; 48 | Future.delayed(const Duration(seconds: 1), () => setting.dispose()); 49 | }); 50 | } 51 | 52 | // 初始化room; 53 | room = MeetingRoom( 54 | roomKey!, 55 | onOpen: (_) => { 56 | Future.delayed( 57 | const Duration(milliseconds: 1000), 58 | () => {_setting()}, 59 | ) 60 | }, 61 | onError: (_) => { 62 | Future.delayed( 63 | const Duration(milliseconds: 3000), 64 | () => { 65 | Get.offNamed(RoutesPath.Home), 66 | }, 67 | ) 68 | }, 69 | ); 70 | room!.initClient(); 71 | return WillPopScope( 72 | child: Scaffold( 73 | backgroundColor: Colors.black87, 74 | appBar: AppBar( 75 | title: Text("会议室:$roomId"), 76 | ), 77 | body: Center( 78 | child: Padding( 79 | padding: const EdgeInsets.only(bottom: 50), 80 | child: SingleChildScrollView( 81 | padding: const EdgeInsets.all(50), 82 | child: GetBuilder( 83 | init: room, 84 | builder: (_) { 85 | return Wrap( 86 | spacing: 10, 87 | runSpacing: 10, 88 | children: room!.users 89 | .map( 90 | (e) => meetingItem(e, _switchStream), 91 | ) 92 | .toList(), 93 | ); 94 | }, 95 | ), 96 | ), 97 | ), 98 | ), 99 | // floatingActionButton: FloatingActionButton( 100 | // onPressed: () => { 101 | // room?.join(MeetingUser(room.counter, name ?? "测试") 102 | // ..stream = stream.clone() 103 | // ..display = true) 104 | // }, 105 | // child: const Icon(Icons.add), 106 | // ), 107 | ), 108 | onWillPop: () async { 109 | debugPrint("leave page"); 110 | room?.leaveRoom(); 111 | // Get.offAllNamed(RoutesPath.Home); 112 | Get.offAndToNamed(RoutesPath.Home); 113 | // debugPrint("back"); 114 | return false; 115 | }, 116 | ); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /client/geek_meeting/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: geek_meeting 2 | description: A new Flutter project. 3 | 4 | # The following line prevents the package from being accidentally published to 5 | # pub.dev using `flutter pub publish`. This is preferred for private packages. 6 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 7 | 8 | # The following defines the version and build number for your application. 9 | # A version number is three numbers separated by dots, like 1.2.43 10 | # followed by an optional build number separated by a +. 11 | # Both the version and the builder number may be overridden in flutter 12 | # build by specifying --build-name and --build-number, respectively. 13 | # In Android, build-name is used as versionName while build-number used as versionCode. 14 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 15 | # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. 16 | # Read more about iOS versioning at 17 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 18 | version: 1.0.0+1 19 | 20 | environment: 21 | sdk: ">=2.12.0 <3.0.0" 22 | 23 | # Dependencies specify other packages that your package needs in order to work. 24 | # To automatically upgrade your package dependencies to the latest versions 25 | # consider running `flutter pub upgrade --major-versions`. Alternatively, 26 | # dependencies can be manually updated by changing the version numbers below to 27 | # the latest version available on pub.dev. To see which dependencies have newer 28 | # versions available, run `flutter pub outdated`. 29 | dependencies: 30 | flutter: 31 | sdk: flutter 32 | 33 | flutter_localizations: 34 | sdk: flutter 35 | 36 | 37 | # The following adds the Cupertino Icons font to your application. 38 | # Use with the CupertinoIcons class for iOS style icons. 39 | cupertino_icons: ^1.0.2 40 | flutter_webrtc: ^0.7.1 41 | get: ^4.3.8 42 | js: ^0.6.3 43 | glass: ^1.0.1+1 44 | dio: ^4.0.3 45 | flutter_easyloading: ^3.0.3 46 | dev_dependencies: 47 | flutter_test: 48 | sdk: flutter 49 | 50 | # The "flutter_lints" package below contains a set of recommended lints to 51 | # encourage good coding practices. The lint set provided by the package is 52 | # activated in the `analysis_options.yaml` file located at the root of your 53 | # package. See that file for information about deactivating specific lint 54 | # rules and activating additional ones. 55 | flutter_lints: ^1.0.0 56 | 57 | # For information on the generic Dart part of this file, see the 58 | # following page: https://dart.dev/tools/pub/pubspec 59 | 60 | # The following section is specific to Flutter. 61 | flutter: 62 | 63 | # The following line ensures that the Material Icons font is 64 | # included with your application, so that you can use the icons in 65 | # the material Icons class. 66 | uses-material-design: true 67 | 68 | # To add assets to your application, add an assets section, like this: 69 | # assets: 70 | # - images/a_dot_burr.jpeg 71 | # - images/a_dot_ham.jpeg 72 | 73 | # An image asset can refer to one or more resolution-specific "variants", see 74 | # https://flutter.dev/assets-and-images/#resolution-aware. 75 | 76 | # For details regarding adding assets from package dependencies, see 77 | # https://flutter.dev/assets-and-images/#from-packages 78 | 79 | # To add custom fonts to your application, add a fonts section here, 80 | # in this "flutter" section. Each entry in this list should have a 81 | # "family" key with the font family name, and a "fonts" key with a 82 | # list giving the asset and other descriptors for the font. For 83 | # example: 84 | # fonts: 85 | # - family: Schyler 86 | # fonts: 87 | # - asset: fonts/Schyler-Regular.ttf 88 | # - asset: fonts/Schyler-Italic.ttf 89 | # style: italic 90 | # - family: Trajan Pro 91 | # fonts: 92 | # - asset: fonts/TrajanPro.ttf 93 | # - asset: fonts/TrajanPro_Bold.ttf 94 | # weight: 700 95 | # 96 | # For details regarding fonts from package dependencies, 97 | # see https://flutter.dev/custom-fonts/#from-packages 98 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/components/dialog/room_recond.dart: -------------------------------------------------------------------------------- 1 | // 会议记录Dialog 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter/widgets.dart'; 4 | import 'package:geek_meeting/models/metting/meeting_recond.dart'; 5 | import 'package:get/get.dart'; 6 | 7 | Future showMeetingRecond(BuildContext context) { 8 | MeetingRecond _recond = MeetingRecond(); 9 | DateTime _currentTime = DateTime.now(); 10 | ScrollController _scrollController = ScrollController(); 11 | _recond.fetch(); 12 | // 监听ListView是否滚动到底部 13 | _scrollController.addListener(() { 14 | if (_scrollController.position.pixels >= 15 | _scrollController.position.maxScrollExtent) { 16 | if (!_recond.isLoading) { 17 | _recond.fetch(); 18 | } 19 | // 这里可以执行上拉加载逻辑 20 | } 21 | }); 22 | // _record.fetch(); 23 | return showDialog( 24 | context: context, 25 | builder: (context) { 26 | return SimpleDialog( 27 | children: [ 28 | Center( 29 | child: Container( 30 | width: 300, 31 | height: 330, 32 | child: Padding( 33 | padding: const EdgeInsets.all(5), 34 | child: GetBuilder( 35 | init: _recond, 36 | builder: (_) => ListView.builder( 37 | controller: _scrollController, 38 | itemCount: _recond.recond.length, 39 | itemBuilder: (_, index) { 40 | var endtime = 41 | DateTime.parse(_recond.recond[index]["end_time"]); 42 | Color color = _currentTime.isAfter(endtime) 43 | ? Colors.grey 44 | : Colors.black; 45 | return Container( 46 | height: 80, 47 | child: Row( 48 | crossAxisAlignment: CrossAxisAlignment.center, 49 | children: [ 50 | Expanded( 51 | child: Icon( 52 | Icons.meeting_room, 53 | size: 50, 54 | color: color, 55 | ), 56 | ), 57 | Expanded( 58 | flex: 4, 59 | child: Column( 60 | mainAxisAlignment: 61 | MainAxisAlignment.spaceAround, 62 | crossAxisAlignment: CrossAxisAlignment.start, 63 | children: [ 64 | Text( 65 | "MEETING - ${_recond.recond[index]["id"]}", 66 | style: TextStyle(color: color), 67 | ), 68 | Text( 69 | "开始时间:${_recond.recond[index]["start_time"]}", 70 | style: TextStyle(color: color), 71 | ), 72 | Text( 73 | "结束时间:${_recond.recond[index]["end_time"]}", 74 | style: TextStyle(color: color), 75 | ), 76 | ], 77 | ), 78 | ) 79 | ], 80 | ), 81 | decoration: BoxDecoration( 82 | border: Border.all(width: 1, color: Colors.grey), 83 | ), 84 | ); 85 | }, 86 | ), 87 | ), 88 | ), 89 | decoration: const BoxDecoration( 90 | color: Colors.white, 91 | borderRadius: BorderRadius.all(Radius.circular(20)), 92 | ), 93 | ), 94 | ), 95 | ], 96 | ); 97 | }, 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /server/GeekMeeting/api/handler/meeting/create.go: -------------------------------------------------------------------------------- 1 | package meeting 2 | 3 | import ( 4 | "GeekMeeting/internal/config" 5 | "GeekMeeting/internal/sqltime" 6 | itypes "GeekMeeting/internal/type" 7 | "GeekMeeting/internal/util" 8 | meetingDb "GeekMeeting/services/database/mysql/meeting" 9 | "context" 10 | "database/sql" 11 | "errors" 12 | "fmt" 13 | "log" 14 | "net/http" 15 | "time" 16 | 17 | "github.com/julienschmidt/httprouter" 18 | ) 19 | 20 | // 创建会议房间 21 | func CreateMeeting(ctx context.Context) httprouter.Handle { 22 | db := ctx.Value(itypes.DatabasesKey).(*sql.DB) 23 | local, _ := time.LoadLocation("Asia/Shanghai") 24 | conf := ctx.Value(itypes.ConfigKey).(config.TomlMap) 25 | roomDb := meetingDb.New(db) 26 | return func(rw http.ResponseWriter, r *http.Request, p httprouter.Params) { 27 | var MasterID int64 = ctx.Value(itypes.UserId).(int64) 28 | var startTime time.Time 29 | var endTime time.Time 30 | startTimeStr := r.PostFormValue("start_time") 31 | endTimeStr := r.PostFormValue("end_time") 32 | password := r.PostFormValue("password") 33 | if password != "" { 34 | password = util.Md5(password) 35 | } 36 | year, month, day := time.Now().Date() 37 | nyear, nmonth, nday := time.Now().AddDate(0, 0, 1).Date() 38 | count, err := roomDb.RoomInDayCount(ctx, meetingDb.RoomInDayCountParams{ 39 | MasterID: MasterID, 40 | CreateTime: sqltime.NullTime{Time: time.Date(year, month, day, 0, 0, 0, 0, local), Valid: true}, 41 | CreateTime_2: sqltime.NullTime{Time: time.Date(nyear, nmonth, nday, 0, 0, 0, 0, local), Valid: true}, 42 | }) 43 | if err != nil { 44 | resp, err := itypes.UnKnowError.ToResp().ToJson() 45 | if err != nil { 46 | log.Println(err) 47 | rw.WriteHeader(http.StatusBadGateway) 48 | return 49 | } 50 | _, _ = rw.Write(resp) 51 | return 52 | } 53 | if count >= conf.Server.Web.RoomLimit { 54 | resp, err := itypes.ErrIntoMyError(errors.New("超出每日可创建房间数的最大限制!")).ToResp().ToJson() 55 | if err != nil { 56 | log.Println(err) 57 | rw.WriteHeader(http.StatusBadGateway) 58 | return 59 | } 60 | _, _ = rw.Write(resp) 61 | return 62 | } 63 | startTime, err1 := time.ParseInLocation(itypes.TimeFormat, startTimeStr, local) 64 | endTime, err2 := time.ParseInLocation(itypes.TimeFormat, endTimeStr, local) 65 | if err1 != nil || err2 != nil { 66 | resp, err := itypes.TimerErr.ToResp().ToJson() 67 | if err != nil { 68 | log.Println(err) 69 | rw.WriteHeader(http.StatusBadGateway) 70 | return 71 | } 72 | _, _ = rw.Write(resp) 73 | return 74 | } 75 | // 校验创建会议的开始时间不能小于当前时间,且最大时常不能大于 conf.Server.Web.MaxTime 76 | diffTime := endTime.Sub(startTime) 77 | if diffTime.Minutes() < 0 || diffTime.Minutes() > float64(conf.Server.Web.MaxTime) { 78 | info := fmt.Sprintf("会议最大时长不能超过%d分钟!", conf.Server.Web.MaxTime) 79 | resp, err := itypes.ErrIntoMyError(errors.New(info)).ToResp().ToJson() 80 | if err != nil { 81 | log.Println(err) 82 | rw.WriteHeader(http.StatusBadGateway) 83 | return 84 | } 85 | _, _ = rw.Write(resp) 86 | return 87 | } 88 | // 会议房间信息写入sql 89 | result, err := roomDb.CreateRoom( 90 | ctx, 91 | meetingDb.CreateRoomParams{ 92 | StartTime: sqltime.NullTime{ 93 | Time: startTime, 94 | Valid: true, 95 | }, 96 | EndTime: sqltime.NullTime{ 97 | Time: endTime, 98 | Valid: true, 99 | }, 100 | Password: password, 101 | MasterID: MasterID, 102 | }, 103 | ) 104 | if err != nil { 105 | resp, err := itypes.CreateRoomErr.ToResp().ToJson() 106 | if err != nil { 107 | log.Println(err) 108 | rw.WriteHeader(http.StatusBadGateway) 109 | return 110 | } 111 | _, _ = rw.Write(resp) 112 | return 113 | } 114 | res := itypes.NoError.ToResp() 115 | RoomID, _ := result.LastInsertId() 116 | res.Data = struct { 117 | RoomId int64 `json:"room_id"` 118 | StartTime string `json:"start_time"` 119 | }{ 120 | RoomId: RoomID, 121 | StartTime: startTime.Format(itypes.TimeFormat), 122 | } 123 | resp, err := res.ToJson() 124 | // res.Data = 125 | if err != nil { 126 | log.Println(err) 127 | rw.WriteHeader(http.StatusBadGateway) 128 | return 129 | } 130 | _, _ = rw.Write(resp) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/models/setting.dart: -------------------------------------------------------------------------------- 1 | import 'dart:html' as html; 2 | import 'package:flutter/cupertino.dart'; 3 | // import 'package:sky_engine/web_audio/dart2js/web_audio_dart2js.dart' as audio; 4 | import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc; 5 | import 'package:flutter_webrtc/flutter_webrtc.dart'; 6 | import 'package:get/get.dart'; 7 | 8 | class SettingModel extends GetxController { 9 | String? _videoDevices; 10 | String? _audioDevices; 11 | String? _audioOutputDevices; 12 | bool _gcRender = false; 13 | bool checkScreen = false; 14 | bool _mute = true; 15 | // 用于渲染预览视频 16 | final localRenderer = RTCVideoRenderer(); 17 | List devices = []; 18 | 19 | SettingModel() { 20 | // AudioElement.created().sinkId 21 | localRenderer.initialize(); 22 | } 23 | 24 | @override 25 | void dispose() { 26 | super.dispose(); 27 | localRenderer.srcObject = null; 28 | localRenderer.dispose(); 29 | } 30 | 31 | bool get gcRender => _gcRender; 32 | 33 | set gcRender(bool sign) { 34 | _gcRender = sign; 35 | update(); 36 | } 37 | 38 | String? get videoDevices => _videoDevices; 39 | 40 | set videoDevices(String? id) { 41 | _videoDevices = id; 42 | if (id != null) { 43 | if (id == "Desktop") { 44 | webrtc.navigator.mediaDevices 45 | .getDisplayMedia({"video": true, "audio": true}).then((stream) { 46 | if (localRenderer.srcObject == null) { 47 | stream.getAudioTracks().forEach((track) { 48 | track.setMicrophoneMute(_mute); 49 | }); 50 | localRenderer.srcObject = stream; 51 | } else { 52 | var tracks = localRenderer.srcObject?.getVideoTracks(); 53 | var videoTracks = stream.getVideoTracks(); 54 | tracks?.forEach((track) { 55 | localRenderer.srcObject?.removeTrack(track); 56 | }); 57 | for (var track in videoTracks) { 58 | localRenderer.srcObject?.addTrack(track); 59 | } 60 | } 61 | }); 62 | } else { 63 | webrtc.navigator.mediaDevices.getUserMedia({ 64 | "video": {"deviceId": id}, 65 | }).then((stream) => localRenderer.srcObject = stream); 66 | } 67 | } 68 | update(); 69 | } 70 | 71 | String? get audioDevices => _audioDevices; 72 | 73 | set audioDevices(String? id) { 74 | _audioDevices = id; 75 | if (id != null) { 76 | if (id == "Desktop") { 77 | // 如果视频输入设备暂未选择,则修改静音标识,否则打开视频音源 78 | if (localRenderer.srcObject == null) { 79 | _mute = false; 80 | } else { 81 | localRenderer.srcObject?.getAudioTracks().forEach((track) { 82 | track.setMicrophoneMute(false); 83 | }); 84 | } 85 | // webrtc.navigator.mediaDevices 86 | // .getDisplayMedia({"video": true, "audio": true}).then( 87 | // (stream) => localRenderer.srcObject = stream); 88 | } else { 89 | webrtc.navigator.mediaDevices.getUserMedia({ 90 | "audio": {"deviceId": id}, 91 | }).then((stream) { 92 | if (localRenderer.srcObject == null) { 93 | localRenderer.srcObject = stream; 94 | } else { 95 | var tracks = localRenderer.srcObject?.getAudioTracks(); 96 | var audioTracks = stream.getAudioTracks(); 97 | tracks?.forEach((track) { 98 | localRenderer.srcObject?.removeTrack(track); 99 | }); 100 | for (var track in audioTracks) { 101 | localRenderer.srcObject?.addTrack(track); 102 | } 103 | // localRenderer.srcObject. 104 | } 105 | // localRenderer.srcObject = stream; 106 | }); 107 | } 108 | } 109 | update(); 110 | } 111 | 112 | String? get audioOutputDevices => _audioOutputDevices; 113 | 114 | set audioOutputDevices(String? id) { 115 | _audioOutputDevices = id; 116 | update(); 117 | } 118 | 119 | // void _listenAudio(webrtc.MediaStream stream) { 120 | // var AudioContext = audio.AudioContext(); 121 | // var s = stream as html.MediaStream; 122 | // AudioContext.createMediaStreamSource(s); 123 | // } 124 | 125 | getDevives() async { 126 | if (devices.isEmpty) { 127 | devices = await webrtc.navigator.mediaDevices.enumerateDevices(); 128 | devices.addAll( 129 | [ 130 | webrtc.MediaDeviceInfo( 131 | label: "Desktop", deviceId: "Desktop", kind: "videoinput"), 132 | webrtc.MediaDeviceInfo( 133 | label: "Desktop", deviceId: "Desktop", kind: "audioinput") 134 | ], 135 | ); // 添加桌面屏幕设备选项 136 | update(); 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /server/GeekMeeting/api/handler/meeting/join.go: -------------------------------------------------------------------------------- 1 | package meeting 2 | 3 | import ( 4 | "GeekMeeting/core/sfu/rooms" 5 | "GeekMeeting/internal/config" 6 | itypes "GeekMeeting/internal/type" 7 | "GeekMeeting/internal/util" 8 | "GeekMeeting/services/cache" 9 | meetingDb "GeekMeeting/services/database/mysql/meeting" 10 | "context" 11 | "database/sql" 12 | "errors" 13 | "fmt" 14 | "log" 15 | "net/http" 16 | "strconv" 17 | "time" 18 | 19 | "github.com/julienschmidt/httprouter" 20 | ) 21 | 22 | // 加入会议房间 23 | func JoinMeeting(ctx context.Context) httprouter.Handle { 24 | db := ctx.Value(itypes.DatabasesKey).(*sql.DB) 25 | // local, _ := time.LoadLocation("Asia/Shanghai") 26 | conf := ctx.Value(itypes.ConfigKey).(config.TomlMap) 27 | cache := ctx.Value(itypes.CacheKey).(cache.Cache) 28 | roomDb := meetingDb.New(db) 29 | return func(rw http.ResponseWriter, r *http.Request, p httprouter.Params) { 30 | userId := ctx.Value(itypes.UserId).(int64) 31 | nameStr := r.PostFormValue("name") 32 | roomIdStr := r.PostFormValue("room_id") 33 | password := r.PostFormValue("password") 34 | if password != "" { 35 | password = util.Md5(password) 36 | } 37 | roomId, err := strconv.ParseInt(roomIdStr, 10, 64) 38 | // log.Println(roomIdStr, "=", roomId) 39 | if err != nil { 40 | // log.Println(err) 41 | resp, err := itypes.RoomIdErr.ToResp().ToJson() 42 | if err != nil { 43 | log.Println(err) 44 | rw.WriteHeader(http.StatusBadGateway) 45 | return 46 | } 47 | _, _ = rw.Write(resp) 48 | return 49 | } 50 | // 校验会议ID和密码 51 | roomInfo, err := roomDb.SelectRoomInfoByIdAndPassword(ctx, 52 | meetingDb.SelectRoomInfoByIdAndPasswordParams{ 53 | ID: int64(roomId), 54 | Password: password, 55 | }, 56 | ) 57 | if err != nil { 58 | // log.Println(err) 59 | resp, err := itypes.RoomIdErr.ToResp().ToJson() 60 | if err != nil { 61 | log.Println(err) 62 | rw.WriteHeader(http.StatusBadGateway) 63 | return 64 | } 65 | _, _ = rw.Write(resp) 66 | return 67 | } 68 | // 校验会议开始&结束时间 69 | currentTime := time.Now() 70 | //roomInfo.StartTime.Time.After(currentTime.Add(time.Minute*5)) || 71 | if roomInfo.EndTime.Time.Before(currentTime) { 72 | resp, err := itypes.MeetingEnded.ToResp().ToJson() 73 | if err != nil { 74 | log.Println(err) 75 | rw.WriteHeader(http.StatusBadGateway) 76 | return 77 | } 78 | _, _ = rw.Write(resp) 79 | return 80 | } 81 | // 如果会议再当前时间的后五分钟之后则提示暂未开始,否则就允许提前或中途进入房间 82 | if roomInfo.StartTime.Time.After(currentTime.Add(time.Minute * 5)) { 83 | resp, err := itypes.MeetingDidNotStart.ToResp().ToJson() 84 | if err != nil { 85 | log.Println(err) 86 | rw.WriteHeader(http.StatusBadGateway) 87 | return 88 | } 89 | _, _ = rw.Write(resp) 90 | return 91 | } 92 | // 检测会议人数是否超限 93 | if rooms.GetNumber(roomId) >= conf.Server.Web.RoomPeople { 94 | info := fmt.Sprintf("会议最大人数不能超过%d分钟!", conf.Server.Web.RoomPeople) 95 | resp, err := itypes.ErrIntoMyError(errors.New(info)).ToResp().ToJson() 96 | if err != nil { 97 | log.Println(err) 98 | rw.WriteHeader(http.StatusBadGateway) 99 | return 100 | } 101 | _, _ = rw.Write(resp) 102 | return 103 | } 104 | // 将暂存信息写入Redis,用于建立信令服务器的标识 105 | joinInfo := itypes.JoinInfo{ 106 | RoomId: roomInfo.ID, 107 | UserId: userId, 108 | UserName: nameStr, 109 | } 110 | joinInfoByte, err := util.NewCoding(util.Gob).Encode(joinInfo) 111 | if err != nil { 112 | resp, err := itypes.InternalError.ToResp().ToJson() 113 | if err != nil { 114 | log.Println(err) 115 | rw.WriteHeader(http.StatusBadGateway) 116 | return 117 | } 118 | _, _ = rw.Write(resp) 119 | return 120 | } 121 | key := fmt.Sprintf("Meeting-%d-%d-%s", roomId, userId, util.Md5FromBytes(joinInfoByte)) 122 | if err := cache.SetEx(ctx, key, joinInfoByte, time.Second*10); err != nil { 123 | log.Println(err) 124 | resp, err := itypes.CacheRoomInfoErr.ToResp().ToJson() 125 | if err != nil { 126 | log.Println(err) 127 | rw.WriteHeader(http.StatusBadGateway) 128 | return 129 | } 130 | _, _ = rw.Write(resp) 131 | return 132 | } 133 | // 加入会议写入会议记录 134 | if err := roomDb.InsertRecond(ctx, meetingDb.InsertRecondParams{ 135 | UserID: userId, 136 | Name: sql.NullString{String: nameStr, Valid: true}, 137 | RoomID: roomInfo.ID, 138 | }); err != nil { 139 | log.Println("InsertRecond Error:", err) 140 | } 141 | 142 | // 返回会议室信息 143 | roomInfo.Expand = key 144 | rsp := itypes.NoError.ToResp() 145 | rsp.Data = roomInfo 146 | resp, err := rsp.ToJson() 147 | if err != nil { 148 | log.Println(err) 149 | rw.WriteHeader(http.StatusBadGateway) 150 | return 151 | } 152 | _, _ = rw.Write(resp) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/models/metting/meeting_user.dart: -------------------------------------------------------------------------------- 1 | // 暂时保留,用于区分用户身份 2 | import 'package:flutter/cupertino.dart'; 3 | import 'package:flutter_webrtc/flutter_webrtc.dart'; 4 | import 'package:get/get.dart'; 5 | 6 | enum userIdentity { 7 | leader, 8 | follower, 9 | } 10 | typedef _callback = Function(); 11 | 12 | class MeetingUser extends GetxController { 13 | late int id; 14 | String name; 15 | String? streamId; 16 | bool _self = false; 17 | bool _display = false; 18 | bool _microphone = false; 19 | bool _hasAudioTracks = false; 20 | MediaStream? _stream; 21 | final videoRenderer = RTCVideoRenderer(); 22 | userIdentity _identity = userIdentity.follower; 23 | final _callback _update; 24 | MeetingUser(this.id, this.name, this._update, 25 | {bool self = false, String? initStreamId}) { 26 | _self = self; 27 | streamId = initStreamId; 28 | videoRenderer.initialize(); 29 | } 30 | 31 | bool get isSelf => _self; 32 | 33 | // String? get streamId => _streamId; 34 | 35 | // set streamId(String? streamId) => _streamId = streamId; 36 | 37 | /// 这里使用stream.id进行用户stream区分,故stream.id不再变动 38 | /// 若重新选择设备后需要产生新的流,则直接进行track替换 39 | set stream(MediaStream? newStream) { 40 | debugPrint("set userId:$id streamid:${newStream?.id}"); 41 | // Helper.switchCamera(track) 42 | if (newStream == null) { 43 | // videoRenderer.srcObject?.dispose(); 44 | _stream = null; 45 | videoRenderer.srcObject = null; 46 | _display = false; 47 | _microphone = false; 48 | _update(); 49 | return; 50 | } 51 | _stream = _stream ?? newStream; 52 | streamId = streamId ?? newStream.id; 53 | videoRenderer.srcObject = _stream; 54 | // debugPrint("video render:${videoRenderer.renderVideo}"); 55 | // _stream?.getTracks().forEach((track) { 56 | // debugPrint( 57 | // "userId:$id trackKind:${track.kind} trackEnable:${track.enabled} trackMuted:${track.muted}"); 58 | // }); 59 | // 更新流,不更换streamid,替换track 60 | if (_stream?.id != newStream.id) { 61 | debugPrint("re streamId"); 62 | // if (_stream!.getVideoTracks().isNotEmpty) { 63 | // _stream!.removeTrack(_stream!.getVideoTracks()[0]); 64 | // } 65 | List? tracks = _stream?.getTracks(); 66 | tracks?.forEach((track) { 67 | track.stop(); 68 | _stream?.removeTrack(track); 69 | }); 70 | tracks?.clear(); 71 | tracks = newStream.getTracks(); 72 | for (var track in tracks) { 73 | _stream?.addTrack(track); 74 | } 75 | } 76 | // end 77 | if (_stream!.getVideoTracks().isNotEmpty) { 78 | debugPrint("set $id display true"); 79 | _display = true; 80 | } 81 | _hasAudioTracks = false; 82 | if (_stream!.getAudioTracks().isNotEmpty) { 83 | debugPrint("set $id microphone true"); 84 | _hasAudioTracks = true; 85 | _microphone = true; 86 | } 87 | _setStreamEvent(); 88 | _update(); 89 | } 90 | 91 | void _setStreamEvent() { 92 | _stream!.getTracks().forEach((track) { 93 | // track停止推流事件 94 | track.onEnded = () { 95 | if (track.kind == 'video') { 96 | _display = false; 97 | } else { 98 | _microphone = false; 99 | } 100 | _update(); 101 | }; 102 | // track静音事件 103 | track.onMute = () { 104 | _microphone = false; 105 | _update(); 106 | }; 107 | // track取消静音事件 108 | track.onUnMute = () { 109 | // 如果audiotracks存在,则触发静音取消事件 110 | _hasAudioTracks ? _microphone = true : null; 111 | _update(); 112 | // _microphone = true; 113 | }; 114 | }); 115 | } 116 | 117 | MediaStream? get stream => _stream; 118 | 119 | bool get display => _display; 120 | 121 | set display(bool state) { 122 | _display = state; 123 | _stream?.getVideoTracks().forEach((track) { 124 | track.enabled = state; 125 | }); 126 | _update(); 127 | } 128 | 129 | bool get microphone => _microphone; 130 | 131 | set microphone(bool state) { 132 | debugPrint("set user:$id microphone state:$state"); 133 | _microphone = state; 134 | _stream?.getAudioTracks().forEach((track) { 135 | track.enabled = state; 136 | }); 137 | _update(); 138 | } 139 | 140 | userIdentity get identity => _identity; 141 | 142 | set identity(userIdentity identity) { 143 | _identity = identity; 144 | _update(); 145 | } 146 | 147 | void disposeStream() { 148 | debugPrint("clear stream"); 149 | videoRenderer.srcObject = null; 150 | _stream?.getTracks().forEach((track) async { 151 | debugPrint("stop track"); 152 | track.onEnded = null; 153 | track.onMute = null; 154 | track.onUnMute = null; 155 | await track.stop(); 156 | }); 157 | // 防止Renderer被页面使用,使用延时关闭 158 | Future.delayed(const Duration(seconds: 1), () { 159 | videoRenderer.dispose(); 160 | debugPrint("dispose stream"); 161 | }); 162 | _stream?.dispose(); 163 | _stream = null; 164 | debugPrint("clear stream end"); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/utils/common.dart: -------------------------------------------------------------------------------- 1 | // 通过1-7来获取周几 2 | import 'dart:convert'; 3 | 4 | import 'package:flutter/cupertino.dart'; 5 | 6 | String? getWeekDay(int day) { 7 | String? result; 8 | switch (day) { 9 | case 1: 10 | result = "一"; 11 | break; 12 | case 2: 13 | result = "二"; 14 | break; 15 | case 3: 16 | result = "三"; 17 | break; 18 | case 4: 19 | result = "四"; 20 | break; 21 | case 5: 22 | result = "五"; 23 | break; 24 | case 6: 25 | result = "六"; 26 | break; 27 | case 7: 28 | result = "日"; 29 | break; 30 | default: 31 | } 32 | return result; 33 | } 34 | 35 | // 格式化时间到MM月DD日 周WeekDay HH:mm 36 | String fomatDate(DateTime date) { 37 | return "${date.month.toString().padLeft(2, '0')}月${date.day.toString().padLeft(2, '0')}日 周${getWeekDay(date.weekday)} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}"; 38 | } 39 | 40 | bool isEmail(String? input) { 41 | String regexEmail = "[\\w\\.]+@\\w+\\.[a-z]{2,3}(\\.[a-z]{2,3})?"; 42 | if (input == null || input.isEmpty) return false; 43 | return RegExp(regexEmail).hasMatch(input); 44 | } 45 | 46 | bool checkToken(String token) { 47 | bool result = false; 48 | try { 49 | var info = base64.normalize(token.split(".")[1]); 50 | var infoStr = utf8.decode(base64Decode(info)); 51 | Map infoMap = jsonDecode(infoStr); 52 | if (infoMap["exp"] > DateTime.now().millisecondsSinceEpoch ~/ 1000) { 53 | result = true; 54 | } 55 | } catch (_) {} 56 | return result; 57 | } 58 | 59 | const p1 = ''' 60 | 用户协议 61 | 62 | (以下简称“本程序”)按照下列条款与条件提供信息和产品,您在本协议中亦可被称为“用户”,以下所述条款和条件将构成您与本程序,就您使用提供的内容所达成的全部协议(以下称“本协议”)。 63 | 64 | 说明 65 | 66 | 本程序向您提供包括但不限于在线会议,在线交流等服务(以下称“本服务”)。 67 | 68 | 权利声明 69 | 70 | 1、程序开发者享有并保留以下各项内容完整的、不可分割的所有权及/或知识产权: 71 | 72 | (1)平台相关的软件、技术、程序、代码、用户界面等; 73 | 74 | 2、本程序为开源项目,用户使用请自觉遵守开源条款。 75 | 责任限制 76 | 77 | 1、本程序向用户提供的服务均是在依"现状"提供,本程序在此明确声明对本服务不作任何明示或暗示的保证,包括但不限于对服务的可适用性、准确性、及时性、可持续性等。 78 | 79 | 2、用户理解并同意自行承担使用本服务的风险,且用户在使用本服务时,应遵循中国法律的相关规定,由于用户行为所造成的任何损害和后果,本程序均不承担除法律有明确规定外的责任。 80 | 81 | 3、不论在何种情况下,本程序均不对由于网络连接故障、通讯线路、第三方网站、电脑硬件等任何原因给用户造成的任何损失承担除法律有明确规定外的责任。 82 | 83 | 用户行为规范 84 | 85 | 1、用户在本网站注册时,不得使用虚假身份信息。用户应当妥善保存其账户信息和密码,由于用户泄密所导致的损失需由用户自行承担。如用户发现他人冒用或盗用其账户或密码,或其账户存在其他未经合法授权使用之情形,应立即以有效方式通知本程序。用户理解并同意本程序有权根据用户的通知、请求或依据判断,采取相应的行动或措施,包括但不限于冻结账户、限制账户功能等,本程序对采取上述行动所导致的损失不承担除法律有明确规定外的责任。 86 | 87 | 2、用户在使用本服务时须遵守法律法规,不得利用本服务从事违法违规行为,包括但不限于: 88 | 89 | (1)发布、传送、传播、储存危害国家安全统一、破坏社会稳定、违反公序良俗、侮辱、诽谤、淫秽、暴力以及任何违反国家法律法规的内容; 90 | 91 | (2)发布、传送、传播、储存侵害他人知识产权、商业秘密等合法权利的内容; 92 | 93 | (3)恶意虚构事实、隐瞒真相以误导、欺骗他人; 94 | 95 | (4)发布、传送、传播广告信息及垃圾信息; 96 | 97 | (5)其他法律法规禁止的行为。 98 | 99 | 3、用户不得利用本服务进行任何有损本程序及其关联企业之权利、利益及商誉,或其他用户合法权利之行为。 100 | 101 | 4、用户不得基于本服务从事制作、使用、传播“私服”、“外挂”等侵害本程序合法权益的行为。如有违反,本程序将依据中国现行法律法规及本程序的相关规定予以处理。 102 | 103 | 5、虚拟财产转移服务外,用户不得通过任何方式直接或变相进行账号等虚拟财产的转移。 104 | 105 | 6、用户不得从事任何利用本程序平台系统漏洞进行有损其他用户、本程序或互联网安全的行为。 106 | 107 | 7、用户知悉并确认,本程序通过公告、邮件、短信、账户通知以及用户在账户中登记的即时通讯工具等方式,向用户发出关于本服务的通知、规则、提示等信息,均为有效通知。该等信息一经公布或发布,即视为已送达至用户。 108 | 109 | 广告信息和促销 110 | 111 | 1、用户同意接受本程序通过公告、邮件、短信、账户通知以及用户在账户中登记的即时通讯工具等方式发送的有关本服务,或本程序、本程序之关联企业或与本程序有合作关系的第三方相关的商品、服务促销或其他商业信息。 112 | 113 | 2、本程序在本服务中可能提供与其他互联网之网站站点或资源的链接,本程序对存在或源于此类网站站点或资源的任何内容、广告、产品或其他资料不予保证或负责;如该链接所载的内容或搜索引擎所提供之链接的内容侵犯用户权利,本程序声明与上述内容无关,且不承担除法律有明确规定外的责任。 114 | '''; 115 | 116 | const p2 = ''' 117 | 本程序非常重视用户的隐私保护,因此制定了本涵盖如何收集、使用、披露、分享以及存储用户的信息的《隐私条款》。用户在使用我们的服务时,我们可能会收集和使用您的相关信息。我们希望通过本《隐私条款》向您说明,在使用我们的服务时,我们如何收集、使用、储存和分享这些信息,以及我们为您提供的访问、更新、控制和保护这些信息的方式。本《隐私条款》适用于用户与本程序的交互行为以及用户注册和使用本程序的服务,我们建议您仔细地阅读本政策,以帮助您了解维护自己隐私权的方式。 118 | 119 | 您使用或继续使用我们的服务,即表示您同意我们按照本《隐私条款》收集、使用、储存和分享您的相关信息。如对本《隐私条款》或相关事宜有任何问题,请进行留言。 120 | 121 | 一、我们可能收集的信息 122 | 123 | 我们提供服务时,可能会收集、储存和使用下列与您有关的信息。如果您不提供相关信息,可能无法注册成为我们的用户或无法享受我们提供的某些服务,或者无法达到相关服务拟达到的效果。 124 | 125 | (一)您提供的信息 126 | 127 | 1、您在注册账户或使用我们的服务时,向我们提供的相关个人信息,例如电子邮件等; 128 | 129 | 2、您通过我们的服务所产生的程序日志内容。 130 | 131 | (二)其他方分享的您的信息 132 | 133 | 在发送电子邮件的时候我们会向电子邮件服务器提供方提供您的邮件信息,除此之外您的信息不会向任何机构分享。 134 | 135 | (三)我们获取的您的信息 136 | 137 | 1、您使用服务时我们可能收集如下信息: 138 | 139 | (1)日志信息,指您使用我们的服务时,系统自动采集的技术信息,包括: 140 | 141 | o 设备或软件信息,例如您的移动设备、网页浏览器或用于接入我们服务的其他程序所提供的配置信息、您的IP地址和移动设备所用的版本和设备识别码; 142 | 143 | o 您通过我们的服务进行通讯的信息,例如曾通讯的账号,以及通讯时间、数据和时长; 144 | 145 | 二、我们如何使用您的信息 146 | 147 | (一)我们可能将在向您提供服务的过程之中所收集的信息用作下列用途: 148 | 149 | 1、向您提供服务。在我们提供服务时,用于身份验证、客户服务、安全防范、诈骗监测、存档和备份用途,确保我们向您提供的产品和服务的安全性; 150 | 151 | 2、帮助我们设计新服务,改善我们现有服务; 152 | 153 | (二)为了让您有更好的体验、改善我们的服务或您同意的其他用途,在符合相关法律法规的前提下,我们可能将通过某一项服务所收集的信息,以汇集信息或者个性化的方式,用于我们的其他服务。例如,在您使用我们的一项服务时所收集的信息,可能在另一服务中用于向您提供特定内容,或向您展示与您相关的、非普遍推送的信息。如果我们在相关服务中提供了相应选项,您也可以授权我们将该服务所提供和储存的信息用于我们的其他服务。 154 | 155 | 三、您如何访问和控制自己的个人信息 156 | 157 | 我们将尽量采取适当的技术手段,保证您可以访问、更新和更正自己的注册信息或使用我们的服务时提供的其他个人信息。在访问、更新、更正和删除前述信息时,我们可能会要求您进行身份验证,以保障账户安全。 158 | 159 | 四、我们如何分享您的信息 160 | 161 | 除以下情形外,未经您同意,我们以及我们的关联公司不会与任何第三方分享您的个人信息: 162 | 163 | (一)我们以及我们的关联公司,可能将您的个人信息与我们的关联公司、合作伙伴及第三方服务供应商、承包商及代理(例如代表我们发出电子邮件或推送通知的通讯服务提供商、为我们提供位置数据的地图服务供应商)分享(他们可能并非位于您所在的法域),用作下列用途: 164 | 165 | o 向您提供我们的服务; 166 | 167 | o 实现“我们可能如何使用信息”部分所述目的; 168 | 169 | o 履行我们在本《隐私条款》或本程序与您达成的其他协议中的义务和行使我们的权利; 170 | 171 | o 理解、维护和改善我们的服务。 172 | 173 | 如我们或我们的关联公司与任何上述第三方分享您的个人信息,我们将努力确保该等第三方在使用您的个人信息时遵守本《隐私条款》及我们要求其遵守的其他适当的保密和安全措施。 174 | 175 | (二)随着我们业务的持续发展,我们以及我们的关联公司有可能进行合并、收购、资产转让或类似的交易,您的个人信息有可能作为此类交易的一部分而被转移。我们将在转移前通知您。 176 | 177 | (三)我们或我们的关联公司还可能为以下需要而保留、保存或披露您的个人信息: 178 | 179 | o 您授权或同意本程序披露的; 180 | 181 | o 遵守适用的法律法规; 182 | 183 | o 遵守法院命令或其他法律程序的规定; 184 | 185 | o 遵守相关政府机关的要求; 186 | 187 | o 为遵守适用的法律法规、维护社会公共利益,或保护我们的客户、我们或我们的集团公司、其他用户或雇员的人身和财产安全或合法权益所合理必需的用途; 188 | 189 | o 根据本程序各服务条款及声明中的相关规定,或者本程序认为必要的其他情形下。 190 | '''; 191 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/models/metting/meeting.dart.bak: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:html'; 3 | import 'package:flutter/cupertino.dart'; 4 | import 'package:flutter_webrtc/flutter_webrtc.dart'; 5 | import 'package:get/get.dart'; 6 | 7 | class MeetingRoom extends GetxController { 8 | late final WebSocket _signalServer; 9 | final MeetingModel _meeting = MeetingModel(); 10 | late RTCPeerConnection _peerConnection; 11 | MeetingModel get meeting => _meeting; 12 | MeetingRoom(String url) { 13 | _signalServer = WebSocket(url); 14 | _initSignal(); 15 | } 16 | 17 | void _initSignal() { 18 | _signalServer.onOpen.listen((event) async { 19 | debugPrint("ws init"); 20 | _peerConnection = await createPeerConnection({}, {}); 21 | }); 22 | _signalServer.onMessage.listen((event) async { 23 | window.console.log(event.data); 24 | var raw = event.data; 25 | Map msg = jsonDecode(raw); 26 | switch (msg['event']) { 27 | case 'candidate': 28 | Map parsed = jsonDecode(msg['data']); 29 | _peerConnection 30 | .addCandidate(RTCIceCandidate(parsed['candidate'], null, 0)); 31 | return; 32 | case 'offer': 33 | Map offer = jsonDecode(msg['data']); 34 | 35 | // SetRemoteDescription and create answer 36 | await _peerConnection.setRemoteDescription( 37 | RTCSessionDescription(offer['sdp'], offer['type'])); 38 | RTCSessionDescription answer = await _peerConnection.createAnswer({}); 39 | await _peerConnection.setLocalDescription(answer); 40 | 41 | // Send answer over WebSocket 42 | _signalServer.sendString(const JsonEncoder().convert({ 43 | 'event': 'answer', 44 | 'data': const JsonEncoder() 45 | .convert({'type': answer.type, 'sdp': answer.sdp}) 46 | })); 47 | return; 48 | } 49 | }); 50 | _signalServer.onError.listen((event) { 51 | window.console.log(event); 52 | }); 53 | } 54 | 55 | void _initPeer() { 56 | // 收到ice消息 57 | _peerConnection.onIceCandidate = (candidate) { 58 | if (candidate == null) { 59 | return; 60 | } 61 | window.console.log("on candidate"); 62 | _signalServer.sendString(const JsonEncoder().convert({ 63 | "event": "candidate", 64 | "data": const JsonEncoder().convert({ 65 | 'sdpMLineIndex': candidate.sdpMlineIndex, 66 | 'sdpMid': candidate.sdpMid, 67 | 'candidate': candidate.candidate, 68 | }) 69 | })); 70 | }; 71 | 72 | // 收到track 73 | _peerConnection.onTrack = (event) async { 74 | if (event.track.kind == 'video' && event.streams.isNotEmpty) { 75 | // var renderer = RTCVideoRenderer(); 76 | // 设置音频输出设备 77 | // renderer.audioOutput = ""; 78 | // await renderer.initialize(); 79 | // renderer.srcObject = event.streams[0]; 80 | // renderer.srcObject!.getAudioTracks()[0].setVolume(); 81 | // 收到stream后与房间内用户进行匹配,并设置用户stream 82 | _meeting.users 83 | .firstWhere((element) => element.streamId == event.streams[0].id) 84 | .stream = event.streams[0]; 85 | } 86 | }; 87 | } 88 | 89 | void pushStream(MediaStream stream) { 90 | stream.getTracks().forEach((track) async { 91 | await _peerConnection.addTrack(track, stream); 92 | }); 93 | } 94 | 95 | @override 96 | void dispose() { 97 | super.dispose(); 98 | _peerConnection.dispose(); 99 | _meeting.users.map((e) => _meeting.leave(e.id)).toSet(); 100 | _signalServer.close(); 101 | } 102 | } 103 | 104 | class MeetingModel extends GetxController { 105 | List users = []; 106 | int get counter => users.length; 107 | // 用户加入 108 | void join(MeetingUser user) { 109 | users.add(user); 110 | update(); 111 | } 112 | 113 | // 用户离开 114 | void leave(int id) { 115 | MeetingUser user = users.firstWhere((user) => user.id == id); 116 | users.remove(user); 117 | update(); 118 | } 119 | } 120 | 121 | // 暂时保留,用于区分用户身份 122 | enum userIdentity { 123 | leader, 124 | follower, 125 | } 126 | 127 | class MeetingUser extends GetxController { 128 | int id; 129 | String name; 130 | String? streamId; 131 | bool _display = false; 132 | bool _microphone = false; 133 | MediaStream? _stream; 134 | final videoRenderer = RTCVideoRenderer(); 135 | userIdentity _identity = userIdentity.follower; 136 | MeetingUser(this.id, this.name) { 137 | videoRenderer.initialize(); 138 | } 139 | 140 | /// 这里使用stream.id进行用户stream区分,故stream.id不再变动 141 | /// 若重新选择设备后需要产生新的流,则直接进行track替换 142 | set stream(MediaStream? stream) { 143 | // debugPrint(stream?.id); 144 | _stream = _stream ?? stream; 145 | videoRenderer.srcObject = stream!; 146 | if (stream.getVideoTracks().isNotEmpty) { 147 | _display = true; 148 | } 149 | if (stream.getAudioTracks().isNotEmpty) { 150 | _microphone = true; 151 | } 152 | _setStreamEvent(); 153 | update(); 154 | } 155 | 156 | void _setStreamEvent() { 157 | stream!.getTracks().forEach((track) { 158 | // track停止推流事件 159 | track.onEnded = () { 160 | if (track.kind == 'video') { 161 | _display = false; 162 | } else { 163 | _microphone = false; 164 | } 165 | }; 166 | // track静音事件 167 | track.onMute = () { 168 | _microphone = false; 169 | }; 170 | // track取消静音事件 171 | track.onUnMute = () { 172 | _microphone = true; 173 | }; 174 | }); 175 | } 176 | 177 | MediaStream? get stream => _stream; 178 | 179 | bool get display => _display; 180 | 181 | set display(bool state) { 182 | _display = state; 183 | update(); 184 | } 185 | 186 | bool get microphone => _microphone; 187 | 188 | set microphone(bool state) { 189 | _microphone = state; 190 | update(); 191 | } 192 | 193 | userIdentity get identity => _identity; 194 | 195 | set identity(userIdentity identity) { 196 | _identity = identity; 197 | update(); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/components/dialog/join_room.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/cupertino.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter_easyloading/flutter_easyloading.dart'; 6 | import 'package:geek_meeting/models/home.dart'; 7 | import 'package:geek_meeting/route/routes_path.dart'; 8 | import 'package:geek_meeting/utils/global.dart'; 9 | import 'package:get/get.dart'; 10 | 11 | // 加入会议Dialog 12 | Future showJoinRoom(BuildContext context) { 13 | final home = HomeModel(); 14 | final TextEditingController _roomId = TextEditingController(); 15 | final TextEditingController _userName = TextEditingController(); 16 | final TextEditingController _passWord = TextEditingController(); 17 | return showDialog( 18 | context: context, 19 | builder: (context) { 20 | return SimpleDialog( 21 | children: [ 22 | Center( 23 | child: Container( 24 | width: 300, 25 | height: 330, 26 | child: Padding( 27 | padding: const EdgeInsets.all(20), 28 | child: Column( 29 | children: [ 30 | SizedBox( 31 | height: 240, 32 | child: GetBuilder( 33 | init: home, 34 | builder: (_) { 35 | return Column( 36 | children: [ 37 | TextField( 38 | controller: _userName, 39 | maxLength: 10, 40 | onTap: () => {home.nameErr = null}, 41 | decoration: InputDecoration( 42 | icon: const Icon(Icons.person_pin), 43 | labelText: "请输入昵称", 44 | errorText: home.nameErr 45 | // suffix: ElevatedButton( 46 | // onPressed: () {}, 47 | // child: const Icon(Icons.send_to_mobile), 48 | // ), 49 | ), 50 | ), 51 | TextField( 52 | controller: _roomId, 53 | maxLength: 6, 54 | keyboardType: TextInputType.number, 55 | onTap: () => {home.errType = null}, 56 | decoration: InputDecoration( 57 | icon: 58 | const Icon(Icons.meeting_room_rounded), 59 | labelText: "请输入房间号", 60 | prefixText: "MEETING - ", 61 | errorText: home.errMsg 62 | // suffix: ElevatedButton( 63 | // onPressed: () {}, 64 | // child: const Icon(Icons.send_to_mobile), 65 | // ), 66 | ), 67 | ), 68 | TextField( 69 | controller: _passWord, 70 | obscureText: true, 71 | maxLength: 6, 72 | onTap: () => {home.passWordErr = false}, 73 | decoration: InputDecoration( 74 | icon: const Icon(Icons.keyboard_outlined), 75 | labelText: "会议密码,没有则留空。", 76 | errorText: 77 | home.passWordErr ? "会议密码错误!" : null, 78 | // suffix: ElevatedButton( 79 | // onPressed: () {}, 80 | // child: const Icon(Icons.send_to_mobile), 81 | // ), 82 | ), 83 | ), 84 | ], 85 | ); 86 | }, 87 | ), 88 | ), 89 | Center( 90 | child: ElevatedButton( 91 | onPressed: () { 92 | if (_userName.text.trim().isEmpty) { 93 | home.nameErr = "昵称不能为空!"; 94 | return; 95 | } 96 | if (_roomId.text.isEmpty) { 97 | home.errType = err.roomIdErr; 98 | return; 99 | } 100 | NetUtil.net.post("/join_meeting", data: { 101 | "name": _userName.text, 102 | "room_id": _roomId.text, 103 | "password": _passWord.text 104 | }, success: (data) { 105 | Map result = jsonDecode(data); 106 | debugPrint(data); 107 | if (result["code"] == 200) { 108 | Get.toNamed(RoutesPath.Meeting, parameters: { 109 | // "name": _userName.text, 110 | "roomId": result["data"]["id"].toString(), 111 | // "password": _passWord.text, 112 | // "uid": result["data"]["uid"].toString(), 113 | "key": result["data"]["expand"].toString(), 114 | }); 115 | } else { 116 | EasyLoading.showError(result["msg"]); 117 | } 118 | }); 119 | }, 120 | child: const Icon( 121 | Icons.keyboard_arrow_right_rounded, 122 | size: 45, 123 | ), 124 | style: ButtonStyle( 125 | shape: MaterialStateProperty.all( 126 | const CircleBorder(), 127 | ), 128 | ), 129 | ), 130 | ), 131 | ], 132 | ), 133 | ), 134 | decoration: const BoxDecoration( 135 | color: Colors.white, 136 | borderRadius: BorderRadius.all(Radius.circular(20)), 137 | ), 138 | ), 139 | ), 140 | ], 141 | ); 142 | }, 143 | ); 144 | } 145 | -------------------------------------------------------------------------------- /server/GeekMeeting/api/handler/meeting/signal/signal.go: -------------------------------------------------------------------------------- 1 | package signal 2 | 3 | import ( 4 | "GeekMeeting/core/sfu/rooms" 5 | itypes "GeekMeeting/internal/type" 6 | "GeekMeeting/internal/util" 7 | meetingDb "GeekMeeting/services/database/mysql/meeting" 8 | "context" 9 | "database/sql" 10 | "encoding/json" 11 | "log" 12 | "net/http" 13 | "time" 14 | 15 | "github.com/julienschmidt/httprouter" 16 | "github.com/pion/webrtc/v3" 17 | ) 18 | 19 | // 信令服务器 20 | func SignalHandler(ctx context.Context) httprouter.Handle { 21 | db := ctx.Value(itypes.DatabasesKey).(*sql.DB) 22 | // conf := ctx.Value(itypes.ConfigKey).(config.TomlMap) 23 | roomId := ctx.Value(itypes.RoomsId).(int64) 24 | name := ctx.Value(itypes.UserName).(string) 25 | userId := ctx.Value(itypes.UserId).(int64) 26 | return func(rw http.ResponseWriter, r *http.Request, p httprouter.Params) { 27 | unsafeConn, err := upgrader.Upgrade(rw, r, nil) 28 | if err != nil { 29 | log.Println("upgrade:", err) 30 | return 31 | } 32 | // webrtc peer 33 | // Create new PeerConnection 34 | peerConnection, err := webrtc.NewPeerConnection(peerConfig) 35 | if err != nil { 36 | log.Print(err) 37 | return 38 | } 39 | 40 | // When this frame returns close the PeerConnection 41 | defer peerConnection.Close() //nolint 42 | 43 | c := rooms.NewSfuConn(unsafeConn, roomId, userId, name, "", peerConnection) 44 | // When this frame returns close the Websocket 45 | // defer c.Close() //nolint 46 | defer c.LeaveRoom() 47 | room := c.JoinRoom() 48 | if room.CheckRoomTimeIsZero() { 49 | // 初始化房间过期时间 50 | db := meetingDb.New(db) 51 | roomRow, err := db.SelectRoomInfoByNo(ctx, roomId) 52 | if err != nil { 53 | log.Println(err) 54 | } else { 55 | room.SetEndTime(roomRow.EndTime.Time) 56 | } 57 | } 58 | // Accept one audio and one video track incoming 59 | for _, typ := range []webrtc.RTPCodecType{webrtc.RTPCodecTypeVideo, webrtc.RTPCodecTypeAudio} { 60 | if _, err := peerConnection.AddTransceiverFromKind(typ, webrtc.RTPTransceiverInit{ 61 | Direction: webrtc.RTPTransceiverDirectionRecvonly, 62 | }); err != nil { 63 | log.Print(err) 64 | return 65 | } 66 | } 67 | 68 | // peerConnection.OnNegotiationNeeded(func() { 69 | // log.Println("OnNegotiationNeeded") 70 | // }) 71 | 72 | // Trickle ICE. Emit server candidate to client 73 | peerConnection.OnICECandidate(func(i *webrtc.ICECandidate) { 74 | if i == nil { 75 | return 76 | } 77 | 78 | candidateString, err := json.Marshal(i.ToJSON()) 79 | if err != nil { 80 | log.Println(err) 81 | return 82 | } 83 | 84 | if writeErr := c.WriteJSON(&websocketMessage{ 85 | Event: "candidate", 86 | Data: string(candidateString), 87 | }); writeErr != nil { 88 | log.Println(writeErr) 89 | } 90 | }) 91 | 92 | // If PeerConnection is closed remove it from global list 93 | peerConnection.OnConnectionStateChange(func(p webrtc.PeerConnectionState) { 94 | switch p { 95 | case webrtc.PeerConnectionStateFailed: 96 | if err := peerConnection.Close(); err != nil { 97 | log.Print(err) 98 | } 99 | case webrtc.PeerConnectionStateClosed: 100 | room.SignalPeerConnections() 101 | case webrtc.PeerConnectionStateConnected: 102 | room.SignalPeerConnections() 103 | } 104 | }) 105 | 106 | peerConnection.OnTrack(func(t *webrtc.TrackRemote, r *webrtc.RTPReceiver) { 107 | log.Printf("RoomId:%d\tUserId:%d\tStreamId:%s\r\n", roomId, userId, r.Track().StreamID()) 108 | // Create a track to fan out our incoming video to all peers 109 | trackLocal := room.AddTrack(t) 110 | defer room.RemoveTrack(trackLocal) 111 | buf := make([]byte, 1500) 112 | for { 113 | i, _, err := t.Read(buf) 114 | if err != nil { 115 | return 116 | } 117 | 118 | if _, err = trackLocal.Write(buf[:i]); err != nil { 119 | return 120 | } 121 | } 122 | }) 123 | // webrtc peer 124 | 125 | msg := userEvent{Uid: userId, Name: name} 126 | data, err := json.Marshal(msg) 127 | if err != nil { 128 | log.Println(err) 129 | return 130 | } 131 | // 用户通过认证,对该用户下发个人信息 132 | if err := c.WriteJSON(&websocketMessage{ 133 | Event: "sync", 134 | Data: string(data), 135 | }); err != nil { 136 | log.Println(err) 137 | return 138 | } 139 | // 用户加入房间,对房间内用户进行广播 140 | room.Broadcast(&websocketMessage{ 141 | Event: "join", 142 | Data: string(data), 143 | }) 144 | // 用户加入房间,同步更早加入房间的用户给新用户 145 | 146 | // 开启心跳 147 | go func() { 148 | timer := time.NewTicker(time.Second * 10) 149 | defer timer.Stop() 150 | var err error 151 | for range timer.C { 152 | err = c.WriteJSON(&websocketMessage{Event: "ping"}) 153 | if err != nil { 154 | event := userEvent{Uid: userId} 155 | msg, err := json.Marshal(event) 156 | if err != nil { 157 | log.Println(err) 158 | } 159 | room.Broadcast(&websocketMessage{ 160 | Event: "leave", 161 | Data: util.BytesToString(msg), 162 | }) 163 | return 164 | } 165 | } 166 | }() 167 | // test 168 | // 用户加入则下发offer,用于同步当前房间已存在的流信息 169 | room.SignalPeerConnections() 170 | message := &websocketMessage{} 171 | for { 172 | _, raw, err := c.ReadMessage() 173 | if err != nil { 174 | log.Println(err) 175 | return 176 | } else if err := json.Unmarshal(raw, &message); err != nil { 177 | log.Println(err) 178 | return 179 | } 180 | 181 | switch message.Event { 182 | case "set_stream": 183 | // 用户流id更新事件 184 | // streamId := message.Data 185 | // room.Broadcast(msg interface{}) 186 | // 房间广播用户流更新 187 | // 校验数据格式 188 | event := userEvent{} 189 | if err := json.Unmarshal([]byte(message.Data), &event); err != nil { 190 | log.Println(err) 191 | return 192 | } 193 | c.SetStreamId(event.StreamID) 194 | // 用户加入事件 195 | room.Broadcast(&websocketMessage{ 196 | Event: message.Event, 197 | Data: message.Data, 198 | }) 199 | case "leave": 200 | // 用户离开事件 201 | event := userEvent{Uid: userId} 202 | msg, err := json.Marshal(event) 203 | if err != nil { 204 | log.Println(err) 205 | } 206 | room.Broadcast(&websocketMessage{ 207 | Event: message.Event, 208 | Data: util.BytesToString(msg), 209 | }) 210 | return 211 | case "candidate": 212 | candidate := webrtc.ICECandidateInit{} 213 | if err := json.Unmarshal([]byte(message.Data), &candidate); err != nil { 214 | log.Println(err) 215 | return 216 | } 217 | 218 | if err := peerConnection.AddICECandidate(candidate); err != nil { 219 | log.Println(err) 220 | return 221 | } 222 | case "answer": 223 | answer := webrtc.SessionDescription{} 224 | if err := json.Unmarshal([]byte(message.Data), &answer); err != nil { 225 | log.Println(err) 226 | return 227 | } 228 | 229 | if err := peerConnection.SetRemoteDescription(answer); err != nil { 230 | log.Println(err) 231 | return 232 | } 233 | case "Renegotiation": 234 | // 客户端主动触发重新协商事件 235 | room.SignalPeerConnections() 236 | } 237 | } 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /server/GeekMeeting/core/sfu/rooms/room.go: -------------------------------------------------------------------------------- 1 | package rooms 2 | 3 | import ( 4 | "GeekMeeting/internal/util" 5 | "encoding/json" 6 | "log" 7 | "sync" 8 | "time" 9 | 10 | "github.com/pion/rtcp" 11 | "github.com/pion/webrtc/v3" 12 | ) 13 | 14 | type peerConnectionState struct { 15 | user *threadSafeWriter 16 | } 17 | 18 | type room struct { 19 | sync.RWMutex 20 | once sync.Once 21 | peerConnections []peerConnectionState 22 | trackLocals map[string]*webrtc.TrackLocalStaticRTP 23 | free chan struct{} 24 | exitTime time.Time 25 | } 26 | 27 | func (r *room) init() { 28 | r.once.Do(func() { 29 | go func() { 30 | timer := time.NewTicker(time.Second * 3) 31 | defer timer.Stop() 32 | for { 33 | select { 34 | case <-timer.C: 35 | r.dispatchKeyFrame() 36 | // timer.Reset(time.Second * 3) 37 | case <-r.free: 38 | return 39 | } 40 | } 41 | }() 42 | // 检测房间到期时间以及房间人数,如果人数为0则停止同步帧并释放这个房间 43 | go func() { 44 | timer := time.NewTicker(time.Second * 30) 45 | defer timer.Stop() 46 | for range timer.C { 47 | // log.Println(r.exitTime) 48 | // log.Println(time.Now()) 49 | if len(r.peerConnections) == 0 || time.Now().After(r.exitTime) { 50 | // 关闭socket 51 | r.RLock() 52 | for index := range r.peerConnections { 53 | _ = r.peerConnections[index].user.Conn.Close() 54 | } 55 | r.RUnlock() 56 | r.free <- struct{}{} 57 | return 58 | } 59 | } 60 | }() 61 | }) 62 | } 63 | 64 | func (r *room) CheckRoomTimeIsZero() bool { 65 | return r.exitTime.IsZero() 66 | } 67 | 68 | func (r *room) SetEndTime(endTime time.Time) { 69 | // log.Println("set endTime", endTime) 70 | r.exitTime = endTime 71 | } 72 | 73 | // 房间内广播 74 | func (r *room) Broadcast(msg interface{}) { 75 | for i := range r.peerConnections { 76 | if err := r.peerConnections[i].user.WriteJSON(msg); err != nil { 77 | log.Println("Broadcast Error:", err.Error()) 78 | } 79 | } 80 | } 81 | 82 | // 用户加入房间 83 | func (r *room) userJoin(user *threadSafeWriter) { 84 | r.Lock() 85 | defer r.Unlock() 86 | // 同步房间已加入人员 87 | for index := range r.peerConnections { 88 | info := UserEvent{Uid: r.peerConnections[index].user.uid, Name: r.peerConnections[index].user.name, StreamID: r.peerConnections[index].user.streamId} 89 | event, err := json.Marshal(info) 90 | if err != nil { 91 | log.Println(err) 92 | continue 93 | } 94 | _ = user.WriteJSON(&websocketMessage{Event: "join", Data: util.BytesToString(event)}) 95 | } 96 | r.peerConnections = append(r.peerConnections, peerConnectionState{user}) 97 | } 98 | 99 | // Add to list of tracks and fire renegotation for all PeerConnections 100 | func (r *room) AddTrack(t *webrtc.TrackRemote) *webrtc.TrackLocalStaticRTP { 101 | r.Lock() 102 | defer func() { 103 | r.Unlock() 104 | r.SignalPeerConnections() 105 | }() 106 | 107 | // Create a new TrackLocal with the same codec as our incoming 108 | trackLocal, err := webrtc.NewTrackLocalStaticRTP(t.Codec().RTPCodecCapability, t.ID(), t.StreamID()) 109 | if err != nil { 110 | panic(err) 111 | } 112 | 113 | r.trackLocals[t.ID()] = trackLocal 114 | return trackLocal 115 | } 116 | 117 | // Remove from list of tracks and fire renegotation for all PeerConnections 118 | func (r *room) RemoveTrack(t *webrtc.TrackLocalStaticRTP) { 119 | r.Lock() 120 | defer func() { 121 | r.Unlock() 122 | r.SignalPeerConnections() 123 | }() 124 | delete(r.trackLocals, t.ID()) 125 | } 126 | 127 | // signalPeerConnections updates each PeerConnection so that it is getting all the expected media tracks 128 | func (r *room) SignalPeerConnections() { 129 | r.Lock() 130 | defer func() { 131 | r.Unlock() 132 | r.dispatchKeyFrame() 133 | }() 134 | 135 | attemptSync := func() (tryAgain bool) { 136 | for i := range r.peerConnections { 137 | if r.peerConnections[i].user.peerConnection.ConnectionState() == webrtc.PeerConnectionStateClosed { 138 | r.peerConnections = append(r.peerConnections[:i], r.peerConnections[i+1:]...) 139 | return true // We modified the slice, start from the beginning 140 | } 141 | 142 | // map of sender we already are seanding, so we don't double send 143 | existingSenders := map[string]bool{} 144 | 145 | // log.Println(r.peerConnections[i].user.uid, "sender=", r.peerConnections[i].user.peerConnection.GetSenders()) 146 | for _, sender := range r.peerConnections[i].user.peerConnection.GetSenders() { 147 | if sender.Track() == nil { 148 | continue 149 | } 150 | 151 | existingSenders[sender.Track().ID()] = true 152 | 153 | // If we have a RTPSender that doesn't map to a existing track remove and signal 154 | if _, ok := r.trackLocals[sender.Track().ID()]; !ok { 155 | if err := r.peerConnections[i].user.peerConnection.RemoveTrack(sender); err != nil { 156 | return true 157 | } 158 | } 159 | } 160 | // log.Println(r.peerConnections[i].user.uid, "receiver=", r.peerConnections[i].user.peerConnection.GetReceivers()) 161 | // Don't receive videos we are sending, make sure we don't have loopback 162 | for _, receiver := range r.peerConnections[i].user.peerConnection.GetReceivers() { 163 | if receiver.Track() == nil { 164 | continue 165 | } 166 | 167 | existingSenders[receiver.Track().ID()] = true 168 | } 169 | 170 | // Add all track we aren't sending yet to the PeerConnection 171 | for trackID := range r.trackLocals { 172 | if _, ok := existingSenders[trackID]; !ok { 173 | // log.Println("add sender to user", r.peerConnections[i].user.uid, "streamId:", r.trackLocals[trackID].StreamID()) 174 | if _, err := r.peerConnections[i].user.peerConnection.AddTrack(r.trackLocals[trackID]); err != nil { 175 | return true 176 | } 177 | } 178 | } 179 | offer, err := r.peerConnections[i].user.peerConnection.CreateOffer(nil) 180 | if err != nil { 181 | return true 182 | } 183 | 184 | if err = r.peerConnections[i].user.peerConnection.SetLocalDescription(offer); err != nil { 185 | return true 186 | } 187 | 188 | offerString, err := json.Marshal(offer) 189 | if err != nil { 190 | return true 191 | } 192 | 193 | if err = r.peerConnections[i].user.WriteJSON(&websocketMessage{ 194 | Event: "offer", 195 | Data: util.BytesToString(offerString), 196 | }); err != nil { 197 | return true 198 | } 199 | } 200 | 201 | return 202 | } 203 | 204 | for syncAttempt := 0; ; syncAttempt++ { 205 | if syncAttempt == 25 { 206 | // Release the lock and attempt a sync in 3 seconds. We might be blocking a RemoveTrack or AddTrack 207 | go func() { 208 | time.Sleep(time.Second * 3) 209 | r.SignalPeerConnections() 210 | }() 211 | return 212 | } 213 | 214 | if !attemptSync() { 215 | break 216 | } 217 | } 218 | } 219 | 220 | // dispatchKeyFrame sends a keyframe to all PeerConnections, used everytime a new user joins the call 221 | func (r *room) dispatchKeyFrame() { 222 | r.Lock() 223 | defer r.Unlock() 224 | 225 | for i := range r.peerConnections { 226 | for _, receiver := range r.peerConnections[i].user.peerConnection.GetReceivers() { 227 | if receiver.Track() == nil { 228 | continue 229 | } 230 | 231 | _ = r.peerConnections[i].user.peerConnection.WriteRTCP([]rtcp.Packet{ 232 | &rtcp.PictureLossIndication{ 233 | MediaSSRC: uint32(receiver.Track().SSRC()), 234 | }, 235 | }) 236 | } 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/components/setting.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/cupertino.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_webrtc/flutter_webrtc.dart'; 4 | import 'package:geek_meeting/models/setting.dart'; 5 | import 'package:get/get.dart'; 6 | 7 | enum _deviceType { videoInput, audioInput, audioOutput } 8 | 9 | Future devices(BuildContext context) async { 10 | SettingModel setting = SettingModel(); 11 | setting.getDevives(); 12 | await showDialog( 13 | context: context, 14 | builder: (context) { 15 | return SimpleDialog( 16 | title: const Text("设备选择"), 17 | children: [ 18 | Container( 19 | width: MediaQuery.of(context).size.height / 2, 20 | height: MediaQuery.of(context).size.height / 2, 21 | padding: const EdgeInsets.all(30), 22 | child: SingleChildScrollView( 23 | child: GetBuilder( 24 | init: setting, 25 | builder: (_) { 26 | return Column( 27 | children: [ 28 | Row( 29 | children: [ 30 | const Expanded( 31 | flex: 2, 32 | child: Text( 33 | "视频输入:", 34 | style: TextStyle(fontSize: 15), 35 | ), 36 | ), 37 | Expanded( 38 | flex: 7, 39 | child: _myDropDown( 40 | setting, 41 | _deviceType.videoInput, 42 | ), 43 | ), 44 | const Expanded( 45 | child: SizedBox(), 46 | ), 47 | ], 48 | ), 49 | Row( 50 | children: [ 51 | const Expanded( 52 | child: Text( 53 | "视频预览:", 54 | style: TextStyle(fontSize: 15), 55 | ), 56 | ), 57 | Expanded( 58 | flex: 2, 59 | child: Container( 60 | color: Colors.grey, 61 | height: 100, 62 | width: 100, 63 | child: setting.gcRender 64 | ? const SizedBox() 65 | : RTCVideoView(setting.localRenderer), 66 | ), 67 | ), 68 | const Expanded( 69 | flex: 2, 70 | child: SizedBox(), 71 | ), 72 | ], 73 | ), 74 | const SizedBox( 75 | height: 10, 76 | ), 77 | Row( 78 | children: [ 79 | const Expanded( 80 | flex: 2, 81 | child: Text( 82 | "音频输入:", 83 | style: TextStyle(fontSize: 15), 84 | ), 85 | ), 86 | Expanded( 87 | flex: 7, 88 | child: _myDropDown( 89 | setting, 90 | _deviceType.audioInput, 91 | ), 92 | ), 93 | const Expanded(child: SizedBox()), 94 | ], 95 | ), 96 | const SizedBox( 97 | height: 10, 98 | ), 99 | Row( 100 | children: [ 101 | const Expanded( 102 | flex: 2, 103 | child: Text( 104 | "音频输出:", 105 | style: TextStyle(fontSize: 15), 106 | ), 107 | ), 108 | Expanded( 109 | flex: 7, 110 | child: _myDropDown( 111 | setting, 112 | _deviceType.audioOutput, 113 | ), 114 | ), 115 | Expanded( 116 | child: InkWell( 117 | child: const Icon( 118 | Icons.play_arrow, 119 | ), 120 | onTap: () { 121 | debugPrint("play test sound"); 122 | }, 123 | ), 124 | ), 125 | ], 126 | ), 127 | ], 128 | ); 129 | }, 130 | ), 131 | ), 132 | ) 133 | ], 134 | ); 135 | }, 136 | ); 137 | return setting; 138 | } 139 | 140 | Widget _myDropDown(SettingModel setting, _deviceType deviceType) { 141 | String? devicesId; 142 | late String filter; 143 | switch (deviceType) { 144 | case _deviceType.videoInput: 145 | devicesId = setting.videoDevices; 146 | filter = "videoinput"; 147 | break; 148 | case _deviceType.audioInput: 149 | filter = "audioinput"; 150 | devicesId = setting.audioDevices; 151 | break; 152 | case _deviceType.audioOutput: 153 | filter = "audiooutput"; 154 | devicesId = setting.audioOutputDevices; 155 | break; 156 | default: 157 | } 158 | return Container( 159 | padding: const EdgeInsets.only(left: 10), 160 | height: 38, 161 | color: Colors.grey[300], 162 | child: DropdownButton( 163 | value: devicesId, 164 | // iconSize: 0, 165 | dropdownColor: Colors.white, 166 | hint: Text(devicesId ?? ""), 167 | underline: const SizedBox(), 168 | onChanged: (v) async { 169 | switch (deviceType) { 170 | case _deviceType.videoInput: 171 | setting.videoDevices = v; 172 | break; 173 | case _deviceType.audioInput: 174 | setting.audioDevices = v; 175 | break; 176 | case _deviceType.audioOutput: 177 | setting.audioOutputDevices = v; 178 | break; 179 | default: 180 | } 181 | }, 182 | items: setting.devices 183 | .where((element) => 184 | element.kind == filter && element.deviceId.isNotEmpty) 185 | .map>((MediaDeviceInfo value) { 186 | return DropdownMenuItem( 187 | value: value.deviceId, 188 | child: 189 | Text(value.deviceId, style: const TextStyle(color: Colors.black)), 190 | ); 191 | }).toList(), 192 | ), 193 | ); 194 | } 195 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/components/dialog/create_room.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/cupertino.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter_easyloading/flutter_easyloading.dart'; 6 | import 'package:geek_meeting/models/create_meeting.dart'; 7 | import 'package:geek_meeting/utils/common.dart'; 8 | import 'package:geek_meeting/utils/global.dart'; 9 | import 'package:get/get.dart'; 10 | 11 | // 创建会议Dialog 12 | Future showCreateRoom(BuildContext context) { 13 | final createMeeting = CreateMeetingModel(); 14 | final TextEditingController _passwd = TextEditingController(); 15 | return showDialog( 16 | context: context, 17 | builder: (context) { 18 | return SimpleDialog( 19 | children: [ 20 | Center( 21 | child: Container( 22 | width: 300, 23 | height: 330, 24 | child: Padding( 25 | padding: const EdgeInsets.all(20), 26 | child: Column( 27 | children: [ 28 | SizedBox( 29 | height: 240, 30 | child: GetBuilder( 31 | init: createMeeting, 32 | builder: (_) { 33 | return Column( 34 | children: [ 35 | // Text(fomatDate(createMeeting.startTime)), 36 | // Text(fomatDate(createMeeting.endTime)), 37 | const Text("创建会议"), 38 | TextField( 39 | readOnly: true, 40 | onTap: () async { 41 | DateTime? startTime = 42 | await selectTime(context); 43 | if (startTime != null) { 44 | createMeeting.startTime = startTime; 45 | } 46 | }, 47 | keyboardType: TextInputType.datetime, 48 | decoration: InputDecoration( 49 | icon: const Icon(Icons.access_time), 50 | hintText: fomatDate(createMeeting.startTime), 51 | helperText: "开始时间", 52 | ), 53 | ), 54 | TextField( 55 | readOnly: true, 56 | onTap: () async { 57 | DateTime? endTime = await selectTime(context, 58 | startTime: createMeeting.startTime); 59 | debugPrint(endTime.toString()); 60 | if (endTime != null && 61 | endTime 62 | .isAfter(createMeeting.startTime)) { 63 | createMeeting.endTime = endTime; 64 | } 65 | }, 66 | decoration: InputDecoration( 67 | icon: const Icon(Icons.access_time), 68 | hintText: fomatDate(createMeeting.endTime), 69 | helperText: "结束时间", 70 | ), 71 | ), 72 | TextField( 73 | controller: _passwd, 74 | maxLength: 6, 75 | onTap: () async {}, 76 | decoration: const InputDecoration( 77 | icon: Icon(Icons.vpn_key_outlined), 78 | hintText: "会议密码", 79 | helperText: "会议密码,留空则不启用。", 80 | ), 81 | ), 82 | ], 83 | ); 84 | }, 85 | ), 86 | ), 87 | const SizedBox( 88 | height: 20, 89 | ), 90 | Center( 91 | child: ElevatedButton( 92 | onPressed: () { 93 | // debugPrint( 94 | // "start time:${createMeeting.startTime.formatNet()}\r\nend time:${createMeeting.endTime.formatNet()}\r\npasswd:${_passwd.text}"); 95 | NetUtil.net.post("/create_meeting", data: { 96 | "start_time": createMeeting.startTime.formatNet(), 97 | "end_time": createMeeting.endTime.formatNet(), 98 | "password": _passwd.text 99 | }, success: (data) { 100 | Map result = jsonDecode(data); 101 | if (result["code"] == 200) { 102 | EasyLoading.showSuccess("会议创建成功!", 103 | dismissOnTap: true) 104 | .then( 105 | (_) => Future.delayed( 106 | const Duration(seconds: 2), 107 | () => Navigator.pop(context), 108 | ), 109 | ); 110 | } else { 111 | // Get.snackbar("系统提示", result["msg"]); 112 | EasyLoading.showError(result["msg"], 113 | dismissOnTap: true); 114 | } 115 | }); 116 | }, 117 | child: const Text("创建会议"), 118 | ), 119 | ), 120 | ], 121 | ), 122 | ), 123 | decoration: const BoxDecoration( 124 | color: Colors.white, 125 | borderRadius: BorderRadius.all(Radius.circular(20)), 126 | ), 127 | ), 128 | ), 129 | ], 130 | ); 131 | }, 132 | ); 133 | } 134 | 135 | Future selectTime(BuildContext context, 136 | {DateTime? startTime}) async { 137 | DateTime? date = await showDatePicker( 138 | context: context, 139 | locale: const Locale('zh'), 140 | initialDate: startTime ?? DateTime.now(), 141 | firstDate: startTime ?? DateTime.now(), 142 | lastDate: startTime ?? DateTime.now().add(const Duration(days: 7)), 143 | cancelText: "取消", 144 | helpText: "选择日期", 145 | confirmText: "选择时间", 146 | initialEntryMode: DatePickerEntryMode.calendarOnly, 147 | ); 148 | if (date == null) return null; 149 | TimeOfDay? time = await showTimePicker( 150 | context: context, 151 | helpText: "选择时间", 152 | initialTime: startTime == null 153 | ? TimeOfDay.now() 154 | : TimeOfDay(hour: startTime.hour, minute: startTime.minute), 155 | ); 156 | if (time == null) return null; 157 | DateTime meetinDate = DateTime( 158 | date.year, 159 | date.month, 160 | date.day, 161 | time.hour, 162 | time.minute, 163 | ); 164 | return meetinDate; 165 | } 166 | -------------------------------------------------------------------------------- /server/GeekMeeting/services/database/mysql/meeting/query.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // source: query.sql 3 | 4 | package meetingDb 5 | 6 | import ( 7 | "context" 8 | "database/sql" 9 | 10 | "GeekMeeting/internal/sqltime" 11 | ) 12 | 13 | const createRoom = `-- name: CreateRoom :execresult 14 | INSERT INTO room (` + "`" + `start_time` + "`" + `,` + "`" + `end_time` + "`" + `,` + "`" + `password` + "`" + `,` + "`" + `master_id` + "`" + `) VALUES (?,?,?,?) 15 | ` 16 | 17 | type CreateRoomParams struct { 18 | StartTime sqltime.NullTime `json:"start_time"` 19 | EndTime sqltime.NullTime `json:"end_time"` 20 | Password string `json:"password"` 21 | MasterID int64 `json:"master_id"` 22 | } 23 | 24 | func (q *Queries) CreateRoom(ctx context.Context, arg CreateRoomParams) (sql.Result, error) { 25 | return q.db.ExecContext(ctx, createRoom, 26 | arg.StartTime, 27 | arg.EndTime, 28 | arg.Password, 29 | arg.MasterID, 30 | ) 31 | } 32 | 33 | const createUser = `-- name: CreateUser :execresult 34 | INSERT INTO users (` + "`" + `email` + "`" + `) VALUES (?) 35 | ` 36 | 37 | func (q *Queries) CreateUser(ctx context.Context, email string) (sql.Result, error) { 38 | return q.db.ExecContext(ctx, createUser, email) 39 | } 40 | 41 | const findUserByEmail = `-- name: FindUserByEmail :one 42 | SELECT Id FROM users WHERE ` + "`" + `email` + "`" + ` = ? 43 | ` 44 | 45 | func (q *Queries) FindUserByEmail(ctx context.Context, email string) (int64, error) { 46 | row := q.db.QueryRowContext(ctx, findUserByEmail, email) 47 | var id int64 48 | err := row.Scan(&id) 49 | return id, err 50 | } 51 | 52 | const findUserById = `-- name: FindUserById :one 53 | SELECT Id FROM users WHERE ` + "`" + `Id` + "`" + ` = ? 54 | ` 55 | 56 | func (q *Queries) FindUserById(ctx context.Context, id int64) (int64, error) { 57 | row := q.db.QueryRowContext(ctx, findUserById, id) 58 | err := row.Scan(&id) 59 | return id, err 60 | } 61 | 62 | const findUserMeetRooms = `-- name: FindUserMeetRooms :many 63 | SELECT room.` + "`" + `Id` + "`" + `,room.` + "`" + `start_time` + "`" + ` FROM room LEFT JOIN users ON users.` + "`" + `Id` + "`" + ` = room.` + "`" + `master_id` + "`" + ` WHERE users.` + "`" + `Id` + "`" + ` = ? 64 | ` 65 | 66 | type FindUserMeetRoomsRow struct { 67 | ID int64 `json:"id"` 68 | StartTime sqltime.NullTime `json:"start_time"` 69 | } 70 | 71 | func (q *Queries) FindUserMeetRooms(ctx context.Context, id int64) ([]FindUserMeetRoomsRow, error) { 72 | rows, err := q.db.QueryContext(ctx, findUserMeetRooms, id) 73 | if err != nil { 74 | return nil, err 75 | } 76 | defer rows.Close() 77 | var items []FindUserMeetRoomsRow 78 | for rows.Next() { 79 | var i FindUserMeetRoomsRow 80 | if err := rows.Scan(&i.ID, &i.StartTime); err != nil { 81 | return nil, err 82 | } 83 | items = append(items, i) 84 | } 85 | if err := rows.Close(); err != nil { 86 | return nil, err 87 | } 88 | if err := rows.Err(); err != nil { 89 | return nil, err 90 | } 91 | return items, nil 92 | } 93 | 94 | const insertRecond = `-- name: InsertRecond :exec 95 | INSERT INTO recond (` + "`" + `user_id` + "`" + `,` + "`" + `name` + "`" + `,` + "`" + `room_id` + "`" + `) VALUES (?,?,?) 96 | ` 97 | 98 | type InsertRecondParams struct { 99 | UserID int64 `json:"user_id"` 100 | Name sql.NullString `json:"name"` 101 | RoomID int64 `json:"room_id"` 102 | } 103 | 104 | func (q *Queries) InsertRecond(ctx context.Context, arg InsertRecondParams) error { 105 | _, err := q.db.ExecContext(ctx, insertRecond, arg.UserID, arg.Name, arg.RoomID) 106 | return err 107 | } 108 | 109 | const roomInDayCount = `-- name: RoomInDayCount :one 110 | SELECT count(Id) FROM room WHERE ` + "`" + `master_id` + "`" + ` = ? AND ` + "`" + `create_time` + "`" + ` BETWEEN ? AND ? 111 | ` 112 | 113 | type RoomInDayCountParams struct { 114 | MasterID int64 `json:"master_id"` 115 | CreateTime sqltime.NullTime `json:"create_time"` 116 | CreateTime_2 sqltime.NullTime `json:"create_time_2"` 117 | } 118 | 119 | func (q *Queries) RoomInDayCount(ctx context.Context, arg RoomInDayCountParams) (int64, error) { 120 | row := q.db.QueryRowContext(ctx, roomInDayCount, arg.MasterID, arg.CreateTime, arg.CreateTime_2) 121 | var count int64 122 | err := row.Scan(&count) 123 | return count, err 124 | } 125 | 126 | const selectRecondByRoomIdAndUserId = `-- name: SelectRecondByRoomIdAndUserId :one 127 | SELECT recond.` + "`" + `room_id` + "`" + `,recond.` + "`" + `name` + "`" + ` FROM recond LEFT JOIN room ON room.` + "`" + `Id` + "`" + ` = recond.` + "`" + `room_id` + "`" + ` WHERE room.` + "`" + `Id` + "`" + ` = ? AND recond.` + "`" + `user_id` + "`" + ` = ? 128 | ` 129 | 130 | type SelectRecondByRoomIdAndUserIdParams struct { 131 | ID int64 `json:"id"` 132 | UserID int64 `json:"user_id"` 133 | } 134 | 135 | type SelectRecondByRoomIdAndUserIdRow struct { 136 | RoomID int64 `json:"room_id"` 137 | Name sql.NullString `json:"name"` 138 | } 139 | 140 | func (q *Queries) SelectRecondByRoomIdAndUserId(ctx context.Context, arg SelectRecondByRoomIdAndUserIdParams) (SelectRecondByRoomIdAndUserIdRow, error) { 141 | row := q.db.QueryRowContext(ctx, selectRecondByRoomIdAndUserId, arg.ID, arg.UserID) 142 | var i SelectRecondByRoomIdAndUserIdRow 143 | err := row.Scan(&i.RoomID, &i.Name) 144 | return i, err 145 | } 146 | 147 | const selectRecondByUserId = `-- name: SelectRecondByUserId :many 148 | SELECT ` + "`" + `Id` + "`" + `,` + "`" + `start_time` + "`" + `,` + "`" + `end_time` + "`" + ` FROM ` + "`" + `room` + "`" + ` WHERE ` + "`" + `master_id` + "`" + ` = ? ORDER BY ` + "`" + `Id` + "`" + ` DESC LIMIT ?,? 149 | ` 150 | 151 | type SelectRecondByUserIdParams struct { 152 | MasterID int64 `json:"master_id"` 153 | Offset int32 `json:"offset"` 154 | Limit int32 `json:"limit"` 155 | } 156 | 157 | type SelectRecondByUserIdRow struct { 158 | ID int64 `json:"id"` 159 | StartTime sqltime.NullTime `json:"start_time"` 160 | EndTime sqltime.NullTime `json:"end_time"` 161 | } 162 | 163 | func (q *Queries) SelectRecondByUserId(ctx context.Context, arg SelectRecondByUserIdParams) ([]SelectRecondByUserIdRow, error) { 164 | rows, err := q.db.QueryContext(ctx, selectRecondByUserId, arg.MasterID, arg.Offset, arg.Limit) 165 | if err != nil { 166 | return nil, err 167 | } 168 | defer rows.Close() 169 | var items []SelectRecondByUserIdRow 170 | for rows.Next() { 171 | var i SelectRecondByUserIdRow 172 | if err := rows.Scan(&i.ID, &i.StartTime, &i.EndTime); err != nil { 173 | return nil, err 174 | } 175 | items = append(items, i) 176 | } 177 | if err := rows.Close(); err != nil { 178 | return nil, err 179 | } 180 | if err := rows.Err(); err != nil { 181 | return nil, err 182 | } 183 | return items, nil 184 | } 185 | 186 | const selectRoomInfoByIdAndPassword = `-- name: SelectRoomInfoByIdAndPassword :one 187 | SELECT Id,start_time,end_time,master_id,expand FROM room WHERE ` + "`" + `Id` + "`" + ` = ? AND ` + "`" + `password` + "`" + ` = ? 188 | ` 189 | 190 | type SelectRoomInfoByIdAndPasswordParams struct { 191 | ID int64 `json:"id"` 192 | Password string `json:"password"` 193 | } 194 | 195 | type SelectRoomInfoByIdAndPasswordRow struct { 196 | ID int64 `json:"id"` 197 | StartTime sqltime.NullTime `json:"start_time"` 198 | EndTime sqltime.NullTime `json:"end_time"` 199 | MasterID int64 `json:"master_id"` 200 | Expand string `json:"expand"` 201 | } 202 | 203 | func (q *Queries) SelectRoomInfoByIdAndPassword(ctx context.Context, arg SelectRoomInfoByIdAndPasswordParams) (SelectRoomInfoByIdAndPasswordRow, error) { 204 | row := q.db.QueryRowContext(ctx, selectRoomInfoByIdAndPassword, arg.ID, arg.Password) 205 | var i SelectRoomInfoByIdAndPasswordRow 206 | err := row.Scan( 207 | &i.ID, 208 | &i.StartTime, 209 | &i.EndTime, 210 | &i.MasterID, 211 | &i.Expand, 212 | ) 213 | return i, err 214 | } 215 | 216 | const selectRoomInfoByNo = `-- name: SelectRoomInfoByNo :one 217 | SELECT Id,start_time,end_time FROM room WHERE ` + "`" + `Id` + "`" + ` = ? 218 | ` 219 | 220 | type SelectRoomInfoByNoRow struct { 221 | ID int64 `json:"id"` 222 | StartTime sqltime.NullTime `json:"start_time"` 223 | EndTime sqltime.NullTime `json:"end_time"` 224 | } 225 | 226 | func (q *Queries) SelectRoomInfoByNo(ctx context.Context, id int64) (SelectRoomInfoByNoRow, error) { 227 | row := q.db.QueryRowContext(ctx, selectRoomInfoByNo, id) 228 | var i SelectRoomInfoByNoRow 229 | err := row.Scan(&i.ID, &i.StartTime, &i.EndTime) 230 | return i, err 231 | } 232 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/pages/login.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:html'; 3 | 4 | import 'package:flutter/material.dart'; 5 | import 'package:flutter_easyloading/flutter_easyloading.dart'; 6 | import 'package:geek_meeting/components/dialog/agreement.dart'; 7 | import 'package:geek_meeting/models/login.dart'; 8 | import 'package:geek_meeting/route/routes_path.dart'; 9 | import 'package:geek_meeting/utils/common.dart'; 10 | import 'package:geek_meeting/utils/global.dart'; 11 | import 'package:get/get.dart'; 12 | 13 | class Login extends StatelessWidget { 14 | Login({Key? key}) : super(key: key); 15 | final TextEditingController _username = TextEditingController(); 16 | final TextEditingController _code = TextEditingController(); 17 | final LoginModel login = LoginModel(); 18 | @override 19 | Widget build(BuildContext context) { 20 | var token = ""; 21 | window.localStorage.forEach((key, value) => { 22 | if (key == "access_token") {token = value} 23 | }); 24 | if (token != "") { 25 | Future.delayed( 26 | const Duration(seconds: 1), () => Get.offAllNamed(RoutesPath.Home)); 27 | } 28 | var size = MediaQuery.of(context).size; 29 | return Scaffold( 30 | body: Center( 31 | child: Container( 32 | padding: const EdgeInsets.only(left: 10, right: 10, top: 50), 33 | width: size.height / 3, 34 | height: size.height / 3, 35 | child: Column( 36 | children: [ 37 | TextField( 38 | controller: _username, 39 | // maxLength: 11, 40 | keyboardType: TextInputType.emailAddress, 41 | decoration: InputDecoration( 42 | icon: const Icon(Icons.mail), 43 | helperText: "未注册账户将自动注册", 44 | labelText: "请输入邮箱地址", 45 | // prefixText: "", 46 | suffix: ElevatedButton( 47 | onPressed: () { 48 | // Get.offAllNamed(RoutesPath.Home); 49 | // return; 50 | // ignore: dead_code 51 | if (!login.userProtocol || !login.privacyProtocol) { 52 | ScaffoldMessenger.of(context).showSnackBar( 53 | const SnackBar( 54 | content: Text("请先阅读并同意以下协议:《用户使用协议》、《用户隐私协议》"), 55 | duration: Duration(milliseconds: 1500), 56 | ), 57 | ); 58 | return; 59 | } 60 | if (!isEmail(_username.text)) { 61 | ScaffoldMessenger.of(context).showSnackBar( 62 | const SnackBar( 63 | content: Text("请输入正确的邮箱地址。"), 64 | duration: Duration(milliseconds: 1500), 65 | ), 66 | ); 67 | return; 68 | } 69 | NetUtil.net.post("/send", data: {"email": _username.text}, 70 | success: (data) { 71 | Map result = jsonDecode(data); 72 | if (result["code"] == 200) { 73 | Get.snackbar("系统通知", "发送成功!"); 74 | login.sendOk(); 75 | } else { 76 | Get.snackbar("系统通知", result["msg"]); 77 | } 78 | }); 79 | // login.sendOk(); 80 | }, 81 | child: GetBuilder( 82 | init: login, 83 | builder: (_) { 84 | return login.time == 120 85 | ? const Icon(Icons.outgoing_mail) 86 | : Text(login.time.toString()); 87 | }, 88 | ), 89 | ), 90 | ), 91 | ), 92 | TextField( 93 | controller: _code, 94 | // maxLength: 11, 95 | keyboardType: TextInputType.number, 96 | decoration: InputDecoration( 97 | icon: const Icon(Icons.verified), 98 | helperText: "请输入邮箱收到的验证码", 99 | labelText: "验证码", 100 | // prefixText: "", 101 | suffix: ElevatedButton( 102 | onPressed: () { 103 | // Get.offAllNamed(RoutesPath.Home); 104 | // return; 105 | // ignore: dead_code 106 | if (!login.userProtocol || !login.privacyProtocol) { 107 | ScaffoldMessenger.of(context).showSnackBar( 108 | const SnackBar( 109 | content: Text("请先阅读并同意以下协议:《用户使用协议》、《用户隐私协议》"), 110 | duration: Duration(milliseconds: 1500), 111 | ), 112 | ); 113 | return; 114 | } 115 | if (!isEmail(_username.text)) { 116 | ScaffoldMessenger.of(context).showSnackBar( 117 | const SnackBar( 118 | content: Text("请输入正确的邮箱地址。"), 119 | duration: Duration(milliseconds: 1500), 120 | ), 121 | ); 122 | return; 123 | } 124 | NetUtil.net.post("/verify", data: { 125 | "email": _username.text, 126 | "verify_code": _code.text 127 | }, success: (data) { 128 | Map result = jsonDecode(data); 129 | // Get.snackbar("系统通知", "发送成功!"); 130 | if (result["code"] == 200 && 131 | result["token"]["access_token"] != "") { 132 | Get.offAllNamed(RoutesPath.Home); 133 | } else { 134 | EasyLoading.showError(result["msg"]); 135 | } 136 | }); 137 | }, 138 | child: const Text("登陆")), 139 | ), 140 | ), 141 | Row( 142 | mainAxisAlignment: MainAxisAlignment.end, 143 | children: [ 144 | GetBuilder( 145 | init: login, 146 | builder: (_) { 147 | return Checkbox( 148 | value: login.userProtocol, 149 | onChanged: (e) => {login.userProtocol = e}, 150 | ); 151 | }, 152 | ), 153 | InkWell( 154 | child: const Text("用户使用协议"), 155 | onTap: () async { 156 | await showMyDiaLog(context, "用户使用协议", p1); 157 | }, 158 | ), 159 | ], 160 | ), 161 | Row( 162 | mainAxisAlignment: MainAxisAlignment.end, 163 | children: [ 164 | GetBuilder( 165 | init: login, 166 | builder: (_) { 167 | return Checkbox( 168 | value: login.privacyProtocol, 169 | onChanged: (e) { 170 | login.privacyProtocol = e; 171 | }, 172 | ); 173 | }, 174 | ), 175 | InkWell( 176 | child: const Text("用户隐私协议"), 177 | onTap: () async { 178 | await showMyDiaLog(context, "用户隐私协议", p2); 179 | }, 180 | ), 181 | ], 182 | ), 183 | ], 184 | ), 185 | decoration: const BoxDecoration( 186 | color: Colors.white, 187 | borderRadius: BorderRadius.all(Radius.circular(20)), 188 | boxShadow: [ 189 | BoxShadow( 190 | color: Colors.grey, 191 | offset: Offset(0.0, 6.0), 192 | blurRadius: 3, 193 | ), 194 | ], 195 | ), 196 | constraints: const BoxConstraints( 197 | minWidth: 280, 198 | minHeight: 300, 199 | ), 200 | ), 201 | ), 202 | ); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /client/geek_meeting/pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | async: 5 | dependency: transitive 6 | description: 7 | name: async 8 | url: "https://pub.flutter-io.cn" 9 | source: hosted 10 | version: "2.8.1" 11 | boolean_selector: 12 | dependency: transitive 13 | description: 14 | name: boolean_selector 15 | url: "https://pub.flutter-io.cn" 16 | source: hosted 17 | version: "2.1.0" 18 | characters: 19 | dependency: transitive 20 | description: 21 | name: characters 22 | url: "https://pub.flutter-io.cn" 23 | source: hosted 24 | version: "1.1.0" 25 | charcode: 26 | dependency: transitive 27 | description: 28 | name: charcode 29 | url: "https://pub.flutter-io.cn" 30 | source: hosted 31 | version: "1.3.1" 32 | clock: 33 | dependency: transitive 34 | description: 35 | name: clock 36 | url: "https://pub.flutter-io.cn" 37 | source: hosted 38 | version: "1.1.0" 39 | collection: 40 | dependency: transitive 41 | description: 42 | name: collection 43 | url: "https://pub.flutter-io.cn" 44 | source: hosted 45 | version: "1.15.0" 46 | cupertino_icons: 47 | dependency: "direct main" 48 | description: 49 | name: cupertino_icons 50 | url: "https://pub.flutter-io.cn" 51 | source: hosted 52 | version: "1.0.4" 53 | dio: 54 | dependency: "direct main" 55 | description: 56 | name: dio 57 | url: "https://pub.flutter-io.cn" 58 | source: hosted 59 | version: "4.0.4" 60 | fake_async: 61 | dependency: transitive 62 | description: 63 | name: fake_async 64 | url: "https://pub.flutter-io.cn" 65 | source: hosted 66 | version: "1.2.0" 67 | ffi: 68 | dependency: transitive 69 | description: 70 | name: ffi 71 | url: "https://pub.flutter-io.cn" 72 | source: hosted 73 | version: "1.1.2" 74 | file: 75 | dependency: transitive 76 | description: 77 | name: file 78 | url: "https://pub.flutter-io.cn" 79 | source: hosted 80 | version: "6.1.2" 81 | flutter: 82 | dependency: "direct main" 83 | description: flutter 84 | source: sdk 85 | version: "0.0.0" 86 | flutter_easyloading: 87 | dependency: "direct main" 88 | description: 89 | name: flutter_easyloading 90 | url: "https://pub.flutter-io.cn" 91 | source: hosted 92 | version: "3.0.3" 93 | flutter_lints: 94 | dependency: "direct dev" 95 | description: 96 | name: flutter_lints 97 | url: "https://pub.flutter-io.cn" 98 | source: hosted 99 | version: "1.0.4" 100 | flutter_localizations: 101 | dependency: "direct main" 102 | description: flutter 103 | source: sdk 104 | version: "0.0.0" 105 | flutter_spinkit: 106 | dependency: transitive 107 | description: 108 | name: flutter_spinkit 109 | url: "https://pub.flutter-io.cn" 110 | source: hosted 111 | version: "5.1.0" 112 | flutter_test: 113 | dependency: "direct dev" 114 | description: flutter 115 | source: sdk 116 | version: "0.0.0" 117 | flutter_webrtc: 118 | dependency: "direct main" 119 | description: 120 | name: flutter_webrtc 121 | url: "https://pub.flutter-io.cn" 122 | source: hosted 123 | version: "0.7.1" 124 | get: 125 | dependency: "direct main" 126 | description: 127 | name: get 128 | url: "https://pub.flutter-io.cn" 129 | source: hosted 130 | version: "4.5.1" 131 | glass: 132 | dependency: "direct main" 133 | description: 134 | name: glass 135 | url: "https://pub.flutter-io.cn" 136 | source: hosted 137 | version: "1.0.1+1" 138 | http_parser: 139 | dependency: transitive 140 | description: 141 | name: http_parser 142 | url: "https://pub.flutter-io.cn" 143 | source: hosted 144 | version: "4.0.0" 145 | intl: 146 | dependency: transitive 147 | description: 148 | name: intl 149 | url: "https://pub.flutter-io.cn" 150 | source: hosted 151 | version: "0.17.0" 152 | js: 153 | dependency: "direct main" 154 | description: 155 | name: js 156 | url: "https://pub.flutter-io.cn" 157 | source: hosted 158 | version: "0.6.3" 159 | lints: 160 | dependency: transitive 161 | description: 162 | name: lints 163 | url: "https://pub.flutter-io.cn" 164 | source: hosted 165 | version: "1.0.1" 166 | matcher: 167 | dependency: transitive 168 | description: 169 | name: matcher 170 | url: "https://pub.flutter-io.cn" 171 | source: hosted 172 | version: "0.12.10" 173 | meta: 174 | dependency: transitive 175 | description: 176 | name: meta 177 | url: "https://pub.flutter-io.cn" 178 | source: hosted 179 | version: "1.7.0" 180 | path: 181 | dependency: transitive 182 | description: 183 | name: path 184 | url: "https://pub.flutter-io.cn" 185 | source: hosted 186 | version: "1.8.0" 187 | path_provider: 188 | dependency: transitive 189 | description: 190 | name: path_provider 191 | url: "https://pub.flutter-io.cn" 192 | source: hosted 193 | version: "2.0.7" 194 | path_provider_android: 195 | dependency: transitive 196 | description: 197 | name: path_provider_android 198 | url: "https://pub.flutter-io.cn" 199 | source: hosted 200 | version: "2.0.9" 201 | path_provider_ios: 202 | dependency: transitive 203 | description: 204 | name: path_provider_ios 205 | url: "https://pub.flutter-io.cn" 206 | source: hosted 207 | version: "2.0.7" 208 | path_provider_linux: 209 | dependency: transitive 210 | description: 211 | name: path_provider_linux 212 | url: "https://pub.flutter-io.cn" 213 | source: hosted 214 | version: "2.1.2" 215 | path_provider_macos: 216 | dependency: transitive 217 | description: 218 | name: path_provider_macos 219 | url: "https://pub.flutter-io.cn" 220 | source: hosted 221 | version: "2.0.3" 222 | path_provider_platform_interface: 223 | dependency: transitive 224 | description: 225 | name: path_provider_platform_interface 226 | url: "https://pub.flutter-io.cn" 227 | source: hosted 228 | version: "2.0.1" 229 | path_provider_windows: 230 | dependency: transitive 231 | description: 232 | name: path_provider_windows 233 | url: "https://pub.flutter-io.cn" 234 | source: hosted 235 | version: "2.0.4" 236 | platform: 237 | dependency: transitive 238 | description: 239 | name: platform 240 | url: "https://pub.flutter-io.cn" 241 | source: hosted 242 | version: "3.0.2" 243 | plugin_platform_interface: 244 | dependency: transitive 245 | description: 246 | name: plugin_platform_interface 247 | url: "https://pub.flutter-io.cn" 248 | source: hosted 249 | version: "2.0.2" 250 | process: 251 | dependency: transitive 252 | description: 253 | name: process 254 | url: "https://pub.flutter-io.cn" 255 | source: hosted 256 | version: "4.2.4" 257 | sky_engine: 258 | dependency: transitive 259 | description: flutter 260 | source: sdk 261 | version: "0.0.99" 262 | source_span: 263 | dependency: transitive 264 | description: 265 | name: source_span 266 | url: "https://pub.flutter-io.cn" 267 | source: hosted 268 | version: "1.8.1" 269 | stack_trace: 270 | dependency: transitive 271 | description: 272 | name: stack_trace 273 | url: "https://pub.flutter-io.cn" 274 | source: hosted 275 | version: "1.10.0" 276 | stream_channel: 277 | dependency: transitive 278 | description: 279 | name: stream_channel 280 | url: "https://pub.flutter-io.cn" 281 | source: hosted 282 | version: "2.1.0" 283 | string_scanner: 284 | dependency: transitive 285 | description: 286 | name: string_scanner 287 | url: "https://pub.flutter-io.cn" 288 | source: hosted 289 | version: "1.1.0" 290 | term_glyph: 291 | dependency: transitive 292 | description: 293 | name: term_glyph 294 | url: "https://pub.flutter-io.cn" 295 | source: hosted 296 | version: "1.2.0" 297 | test_api: 298 | dependency: transitive 299 | description: 300 | name: test_api 301 | url: "https://pub.flutter-io.cn" 302 | source: hosted 303 | version: "0.4.2" 304 | typed_data: 305 | dependency: transitive 306 | description: 307 | name: typed_data 308 | url: "https://pub.flutter-io.cn" 309 | source: hosted 310 | version: "1.3.0" 311 | vector_math: 312 | dependency: transitive 313 | description: 314 | name: vector_math 315 | url: "https://pub.flutter-io.cn" 316 | source: hosted 317 | version: "2.1.0" 318 | win32: 319 | dependency: transitive 320 | description: 321 | name: win32 322 | url: "https://pub.flutter-io.cn" 323 | source: hosted 324 | version: "2.3.1" 325 | xdg_directories: 326 | dependency: transitive 327 | description: 328 | name: xdg_directories 329 | url: "https://pub.flutter-io.cn" 330 | source: hosted 331 | version: "0.2.0" 332 | sdks: 333 | dart: ">=2.14.0 <3.0.0" 334 | flutter: ">=2.5.0" 335 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/utils/net.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:html'; 3 | 4 | import 'package:dio/dio.dart'; 5 | import 'package:flutter/cupertino.dart'; 6 | import 'package:flutter/material.dart'; 7 | import 'package:flutter_easyloading/flutter_easyloading.dart'; 8 | import 'package:geek_meeting/route/routes_path.dart'; 9 | import 'package:geek_meeting/utils/common.dart'; 10 | import 'package:get/get.dart'; 11 | import 'package:dio/src/response.dart' as d; 12 | 13 | typedef BackError = Function(int? code, String msg); 14 | defaultError(int? code, String msg) { 15 | Get.showSnackbar(GetSnackBar( 16 | title: "系统错误", 17 | message: msg, 18 | icon: const Icon( 19 | Icons.dangerous, 20 | color: Colors.red, 21 | ), 22 | duration: const Duration(seconds: 3), 23 | )); 24 | } 25 | 26 | class Net { 27 | late Dio dio; 28 | static bool _refreshing = false; 29 | static const String baseUrl = 'http://127.0.0.1:8082'; 30 | static late List _recond; 31 | //普通格式的header 32 | static final Map headers = { 33 | "Accept": "application/json", 34 | "Content-Type": "application/x-www-form-urlencoded", 35 | // "Access-Control-Request-Method": "GET, POST, PATCH, PUT, OPTIONS" 36 | }; 37 | Net() { 38 | _recond = []; 39 | BaseOptions options = BaseOptions(); 40 | //注册请求服务器 41 | options.baseUrl = baseUrl; 42 | //设置连接超时单位毫秒 43 | options.connectTimeout = 5000; 44 | // 响应流上前后两次接受到数据的间隔,单位为毫秒。如果两次间隔超过[receiveTimeout], 45 | // [Dio] 将会抛出一个[DioErrorType.RECEIVE_TIMEOUT]的异常. 46 | // 注意: 这并不是接收数据的总时限. 47 | options.receiveTimeout = 3000; 48 | //设置请求超时单位毫秒 49 | options.sendTimeout = 5000; 50 | //如果返回数据是json(content-type), 51 | // dio默认会自动将数据转为json, 52 | // 无需再手动转](https://github.com/flutterchina/dio/issues/30) 53 | options.responseType = ResponseType.json; 54 | options.headers = headers; 55 | 56 | dio = Dio(options); 57 | 58 | dio.interceptors 59 | .add(InterceptorsWrapper(onRequest: (options, handler) async { 60 | EasyLoading.show(dismissOnTap: true); 61 | // Do something before request is sent 62 | debugPrint("before request"); 63 | var accessToken = ""; 64 | var refreshToken = ""; 65 | window.localStorage.forEach((key, value) { 66 | if (key == "access_token") { 67 | accessToken = value; 68 | } 69 | if (key == "refresh_token") { 70 | refreshToken = value; 71 | } 72 | }); 73 | // 刷新token,为防止重复刷新,需要添加一个标识区别是否正在进行token刷新 74 | if (!_refreshing && 75 | !checkToken(accessToken) && 76 | checkToken(refreshToken)) { 77 | debugPrint("start refresh"); 78 | _refreshing = true; 79 | try { 80 | await dio 81 | .post("/refresh_token", data: {"refresh_token": refreshToken}); 82 | } catch (e) { 83 | defaultError(0, "服务器连接失败!"); 84 | debugPrint(e.toString()); 85 | } finally { 86 | _recond.remove("/refresh_token"); 87 | _refreshing = false; 88 | } 89 | // 更新token完成,重新获取本地token 90 | window.localStorage.forEach((key, value) { 91 | if (key == "access_token") { 92 | accessToken = value; 93 | } 94 | if (key == "refresh_token") { 95 | refreshToken = value; 96 | } 97 | }); 98 | _refreshing = false; 99 | } 100 | if (accessToken != "") { 101 | options.headers.addAll({"Authorization": "Bearer " + accessToken}); 102 | } 103 | return handler.next(options); //continue 104 | // 如果你想完成请求并返回一些自定义数据,你可以resolve一个Response对象 `handler.resolve(response)`。 105 | // 这样请求将会被终止,上层then会被调用,then中返回的数据将是你的自定义response. 106 | // 107 | // 如果你想终止请求并触发一个错误,你可以返回一个`DioError`对象,如`handler.reject(error)`, 108 | // 这样请求将被中止并触发异常,上层catchError会被调用。 109 | }, onResponse: (response, handler) { 110 | EasyLoading.dismiss(); 111 | debugPrint("onresponse"); 112 | // 添加拦截器,处理token 113 | Map data; 114 | if (response.statusCode == HttpStatus.ok) { 115 | data = jsonDecode(response.data); 116 | debugPrint(data.toString()); 117 | debugPrint(data["token"].toString()); 118 | debugPrint(data["token"]["access_token"].toString()); 119 | if (data["token"]["access_token"].toString() != "") { 120 | window.localStorage.addAll({ 121 | "access_token": data["token"]["access_token"]!, 122 | "refresh_token": data["token"]["refresh_token"] 123 | }); 124 | } 125 | } else if (response.statusCode == HttpStatus.unauthorized) { 126 | window.localStorage.remove("access_token"); 127 | window.localStorage.remove("refresh_token"); 128 | Get.showSnackbar( 129 | const GetSnackBar( 130 | title: "提示", 131 | message: "身份验证过期,请重新登陆。", 132 | ), 133 | ); 134 | try { 135 | Future.delayed(const Duration(milliseconds: 100), 136 | () => {window.location.href = "/"}); 137 | } catch (_) {} 138 | // var access_token = ""; 139 | // var refresh_token = ""; 140 | // window.localStorage.forEach((key, value) { 141 | // if (key == "access_token") { 142 | // access_token = value; 143 | // } 144 | // if (key == "refresh_token") { 145 | // refresh_token = value; 146 | // } 147 | // }); 148 | // 检测是否双token全部过期 149 | // if (!checkToken(access_token) && !checkToken(refresh_token)) { 150 | // window.localStorage.remove("access_token"); 151 | // window.localStorage.remove("refresh_token"); 152 | // Get.showSnackbar( 153 | // GetSnackBar( 154 | // title: "提示", 155 | // message: "身份验证过期,请重新登陆。", 156 | // snackbarStatus: (status) { 157 | // if (status != null) { 158 | // if (status == SnackbarStatus.CLOSED) { 159 | // try { 160 | // Future.delayed(const Duration(milliseconds: 100), 161 | // () => {Get.offNamed(RoutesPath.Home)}); 162 | // } catch (e) {} 163 | // } 164 | // } 165 | // }, 166 | // ), 167 | // ); 168 | // } 169 | return; 170 | } 171 | // Do something with response data 172 | return handler.next(response); // continue 173 | // 如果你想终止请求并触发一个错误,你可以 reject 一个`DioError`对象,如`handler.reject(error)`, 174 | // 这样请求将被中止并触发异常,上层catchError会被调用。 175 | }, onError: (DioError e, handler) { 176 | EasyLoading.dismiss(); 177 | // Do something with response error 178 | if (e.response?.statusCode == HttpStatus.unauthorized) { 179 | window.localStorage.remove("access_token"); 180 | window.localStorage.remove("refresh_token"); 181 | Get.showSnackbar( 182 | const GetSnackBar( 183 | title: "提示", 184 | message: "身份验证过期,请重新登陆。", 185 | ), 186 | ); 187 | try { 188 | Future.delayed(const Duration(milliseconds: 1000), 189 | () => {window.location.href = "/"}); 190 | } catch (_) {} 191 | return; 192 | } 193 | return handler.next(e); //continue 194 | // 如果你想完成请求并返回一些自定义数据,可以resolve 一个`Response`,如`handler.resolve(response)`。 195 | // 这样请求将会被终止,上层then会被调用,then中返回的数据将是你的自定义response. 196 | })); 197 | } 198 | 199 | void get(String url, 200 | {Map? data, 201 | success, 202 | BackError? error = defaultError}) async { 203 | debugPrint("start get request"); 204 | if (_recond.contains(url)) { 205 | return; 206 | } 207 | _recond.add(url); 208 | d.Response response; 209 | try { 210 | if (data == null) { 211 | response = await dio.get(url); 212 | } else { 213 | response = await dio.get(url, queryParameters: data); 214 | } 215 | 216 | if (response.statusCode == HttpStatus.ok) { 217 | success(response.data); 218 | } else { 219 | if (error != null) { 220 | error(response.statusCode, "服务异常!"); 221 | } 222 | debugPrint(response.statusCode.toString()); 223 | } 224 | } on DioError catch (e) { 225 | if (error != null) { 226 | error(null, "服务异常!"); 227 | } 228 | debugPrint(e.toString()); 229 | } finally { 230 | _recond.remove(url); 231 | } 232 | } 233 | 234 | void post(String url, 235 | {Map? data, 236 | success, 237 | BackError? error = defaultError}) async { 238 | debugPrint("start post request"); 239 | if (_recond.contains(url)) { 240 | return; 241 | } 242 | _recond.add(url); 243 | d.Response response; 244 | try { 245 | if (data == null) { 246 | response = await dio.post(url); 247 | } else { 248 | response = await dio.post(url, data: data); 249 | } 250 | 251 | if (response.statusCode == 200) { 252 | success(response.data); 253 | } else { 254 | if (error != null) { 255 | error(response.statusCode, "服务异常!"); 256 | } 257 | debugPrint(response.statusCode.toString()); 258 | } 259 | } on DioError catch (e) { 260 | if (error != null) { 261 | error(null, "服务异常!"); 262 | } 263 | debugPrint(e.toString()); 264 | } finally { 265 | _recond.remove(url); 266 | } 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/models/metting/meeting_room.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:html'; 3 | import 'package:flutter/cupertino.dart'; 4 | import 'package:flutter_easyloading/flutter_easyloading.dart'; 5 | import 'package:flutter_webrtc/flutter_webrtc.dart'; 6 | import 'package:get/get.dart'; 7 | 8 | import 'meeting_user.dart'; 9 | 10 | typedef _callback = Function(dynamic e); 11 | 12 | class MeetingRoom extends GetxController { 13 | late final WebSocket _signalServer; 14 | final List users = []; 15 | late MeetingUser _self; 16 | int get counter => users.length; 17 | late RTCPeerConnection _peerConnection; 18 | final Map _streams = {}; 19 | final String _url = "ws://127.0.0.1:8082/signal/"; 20 | late String _key = ""; 21 | String? _audioOutput; 22 | _callback? _onError; 23 | _callback? _onOpen; 24 | 25 | MeetingRoom(String key, {_callback? onError, _callback? onOpen}) { 26 | // _initSignal(); 27 | _key = key; 28 | // _self = self; 29 | // users.add(_self); 30 | _onError = onError; 31 | _onOpen = onOpen; 32 | } 33 | 34 | set audioOutput(String? deviceId) { 35 | _audioOutput = deviceId; 36 | if (_audioOutput != null) { 37 | for (var user in users) { 38 | user.videoRenderer.audioOutput = _audioOutput!; 39 | } 40 | } 41 | } 42 | 43 | Future initClient() async { 44 | String wsUrl = _url + _key; 45 | _signalServer = WebSocket(wsUrl); 46 | _initSignal(); 47 | _initPeer(); 48 | // _verifyId(); 49 | // await _initPeer(); 50 | return; 51 | } 52 | 53 | // 验证用户身份 54 | // void _verifyId() { 55 | // _signalServer.sendString(const JsonEncoder() 56 | // .convert({'event': 'verify', 'data': _self.id.toString()})); 57 | // } 58 | // 初始化用户流 59 | void initSelfStream(MediaStream? stream) { 60 | debugPrint("init stream $stream"); 61 | stream?.getTracks().forEach((track) async { 62 | debugPrint("add track"); 63 | await _peerConnection.addTrack(track, stream); 64 | debugPrint("add track end"); 65 | }); 66 | // 如果是第一次获取流,则广播自己的流id 67 | if (_self.stream == null && stream != null) { 68 | _signalServer.sendString(const JsonEncoder().convert({ 69 | 'event': 'set_stream', 70 | 'data': const JsonEncoder() 71 | .convert({'uid': _self.id, 'streamId': stream.id}) 72 | })); 73 | } 74 | // _signalServer.sendString(const JsonEncoder().convert({ 75 | // "event": "Renegotiation", 76 | // })); 77 | _self.stream = stream; 78 | } 79 | 80 | // 用户更换流 81 | void switchSelfStream(MediaStream? stream) { 82 | _self.stream = stream; 83 | } 84 | 85 | void _initSignal() { 86 | _signalServer.onOpen.listen((event) async { 87 | if (_onOpen != null) { 88 | _onOpen!(event); 89 | } 90 | debugPrint("start listener success"); 91 | }); 92 | _signalServer.onMessage.listen((event) async { 93 | // window.console.log(event.data); 94 | var raw = event.data; 95 | Map msg = jsonDecode(raw); 96 | switch (msg['event']) { 97 | // 同步上个页面所填写的个人信息 98 | case 'sync': 99 | Map parsed = jsonDecode(msg['data']); 100 | try { 101 | int uid = parsed['uid']; 102 | String name = parsed['name']; 103 | _self = MeetingUser(uid, name, () => update(), self: true); 104 | users.add(_self); 105 | update(); 106 | } catch (e) { 107 | debugPrint(parsed.toString()); 108 | debugPrint(e.toString()); 109 | } 110 | return; 111 | // 用户流ID更新事件 112 | case 'set_stream': 113 | Map parsed = jsonDecode(msg['data']); 114 | try { 115 | int uid = parsed['uid']; 116 | String streamId = parsed['streamId']; 117 | // if (uid == _self.id) { 118 | // _self.streamId = _self.streamId ?? streamId; 119 | // } else { 120 | // MeetingUser? user = 121 | // users.firstWhereOrNull((element) => element.id == uid); 122 | // user?.streamId = streamId; 123 | // } 124 | MeetingUser? user = 125 | users.firstWhereOrNull((element) => element.id == uid); 126 | user?.streamId = streamId; 127 | MediaStream? stream = _streams[streamId]; 128 | _streams.forEach((key, value) { 129 | debugPrint("streams $key = ${value.id}"); 130 | }); 131 | debugPrint("set user ${user?.id} streamId ${parsed['streamId']}"); 132 | user?.stream = stream ?? user.stream; 133 | // update(); 134 | _streams.removeWhere((key, value) => key == streamId); 135 | } catch (e) { 136 | debugPrint(e.toString()); 137 | } 138 | return; 139 | // 用户加入事件 140 | case 'join': 141 | Map parsed = jsonDecode(msg['data']); 142 | try { 143 | MeetingUser user = MeetingUser( 144 | parsed["uid"], parsed["name"], () => update(), 145 | initStreamId: 146 | parsed["streamId"] == "" ? null : parsed["streamId"]); 147 | MediaStream? stream = _streams[user.streamId]; 148 | _streams.forEach((key, value) { 149 | debugPrint("join user streams $key = ${value.id}"); 150 | }); 151 | user.stream = stream ?? user.stream; 152 | // 设置音频输出设备 153 | if (_audioOutput != null) { 154 | user.videoRenderer.audioOutput = _audioOutput!; 155 | } 156 | // update(); 157 | _streams.removeWhere((key, value) => key == user.streamId); 158 | join(user); 159 | } catch (e) { 160 | debugPrint(e.toString()); 161 | } 162 | return; 163 | // 用户离开事件 164 | case 'leave': 165 | Map parsed = jsonDecode(msg['data']); 166 | try { 167 | leave(parsed["uid"]); 168 | } catch (e) { 169 | debugPrint(e.toString()); 170 | } 171 | return; 172 | // webrtc事件 173 | case 'candidate': 174 | Map parsed = jsonDecode(msg['data']); 175 | _peerConnection 176 | .addCandidate(RTCIceCandidate(parsed['candidate'], null, 0)); 177 | return; 178 | case 'offer': 179 | Map offer = jsonDecode(msg['data']); 180 | 181 | // SetRemoteDescription and create answer 182 | await _peerConnection.setRemoteDescription( 183 | RTCSessionDescription(offer['sdp'], offer['type'])); 184 | RTCSessionDescription answer = await _peerConnection.createAnswer({}); 185 | await _peerConnection.setLocalDescription(answer); 186 | 187 | // Send answer over WebSocket 188 | _signalServer.sendString(const JsonEncoder().convert({ 189 | 'event': 'answer', 190 | 'data': const JsonEncoder() 191 | .convert({'type': answer.type, 'sdp': answer.sdp}) 192 | })); 193 | return; 194 | } 195 | }); 196 | _signalServer.onError.listen((event) { 197 | EasyLoading.showError("加入房间失败!"); 198 | if (_onError != null) { 199 | _onError!(event); 200 | } 201 | window.console.log(event); 202 | }); 203 | _signalServer.onClose.listen((event) { 204 | EasyLoading.showInfo("房间已关闭!"); 205 | if (_onError != null) { 206 | _onError!(event); 207 | } 208 | window.console.log(event); 209 | }); 210 | } 211 | 212 | Future _initPeer() async { 213 | var _iceServers = { 214 | 'iceServers': [ 215 | { 216 | "url": "stun:stun.l.google.com:19302", 217 | }, 218 | { 219 | 'url': 'turn:numb.viagenie.ca', 220 | 'credential': 'muazkh', 221 | 'username': 'webrtc@live.com' 222 | }, 223 | ], 224 | // 'iceTransportPolicy': 'relay', 225 | }; 226 | _peerConnection = await createPeerConnection({..._iceServers}, {}); 227 | _peerConnection.onRenegotiationNeeded = () { 228 | // debugPrint("need Renegotiation"); 229 | _signalServer.sendString(const JsonEncoder().convert({ 230 | "event": "Renegotiation", 231 | })); 232 | }; 233 | _peerConnection.onIceConnectionState = (state) { 234 | debugPrint("onIceConnectionState:${state.toString()}"); 235 | }; 236 | _peerConnection.onConnectionState = (state) { 237 | window.console.log(state.toString()); 238 | // debugPrint("onConnectionState:${state.toString()}"); 239 | if (state == RTCPeerConnectionState.RTCPeerConnectionStateDisconnected) { 240 | EasyLoading.showError("由于您的网络环境原因,可能暂不支持加入会话"); 241 | } 242 | }; 243 | // 收到ice消息 244 | _peerConnection.onIceCandidate = (candidate) { 245 | if (candidate == null) { 246 | return; 247 | } 248 | window.console.log("on candidate"); 249 | _signalServer.sendString(const JsonEncoder().convert({ 250 | "event": "candidate", 251 | "data": const JsonEncoder().convert({ 252 | 'sdpMLineIndex': candidate.sdpMlineIndex, 253 | 'sdpMid': candidate.sdpMid, 254 | 'candidate': candidate.candidate, 255 | }) 256 | })); 257 | }; 258 | 259 | // 收到track 260 | _peerConnection.onTrack = (event) async { 261 | debugPrint("recv track ${event.streams[0].id},users:${users.length}"); 262 | // users.map((e) { 263 | // debugPrint("${e.streamId} === ${event.streams[0].id}"); 264 | // }).toSet(); 265 | // event.track.kind == 'video' && 266 | if (event.streams.isNotEmpty) { 267 | // var renderer = RTCVideoRenderer(); 268 | // 设置音频输出设备 269 | // renderer.audioOutput = ""; 270 | // await renderer.initialize(); 271 | // renderer.srcObject = event.streams[0]; 272 | // renderer.srcObject!.getAudioTracks()[0].setVolume(); 273 | // 收到stream后与房间内用户进行匹配,并设置用户stream 274 | MeetingUser? user = users 275 | .firstWhereOrNull((user) => user.streamId == event.streams[0].id); 276 | // 如果用户还未加入房间,则先暂存流数据 277 | if (user == null) { 278 | debugPrint("cache stream"); 279 | _streams[event.streams[0].id] = event.streams[0]; 280 | } else { 281 | user.stream = event.streams[0]; 282 | // 设置音频输出设备 283 | if (_audioOutput != null) { 284 | user.videoRenderer.audioOutput = _audioOutput!; 285 | } 286 | // update(); 287 | } 288 | // debugPrint(users.toList().toString()); 289 | } 290 | }; 291 | 292 | // RemoteStream事件 293 | _peerConnection.onRemoveStream = (stream) { 294 | debugPrint("remove stream ${stream.id}"); 295 | // Filter existing renderers for the stream that has been stopped 296 | for (var r in users) { 297 | if (r.videoRenderer.srcObject?.id == stream.id) { 298 | r.stream = null; 299 | } 300 | } 301 | _streams.removeWhere((key, value) => value.id == stream.id); 302 | }; 303 | return; 304 | } 305 | 306 | // 用户加入 307 | void join(MeetingUser user) { 308 | // debugPrint("Join - - - ${user.toString()}"); 309 | // debugPrint("Join Befor - - - ${users.length.toString()}"); 310 | users.addIf( 311 | users.firstWhereOrNull((element) => element.id == user.id) == null, 312 | user); 313 | // debugPrint("Join after - - - ${users.length.toString()}"); 314 | update(); 315 | } 316 | 317 | // 用户离开 318 | void leave(int id) { 319 | MeetingUser? user = users.firstWhereOrNull((user) => user.id == id); 320 | user?.display = false; 321 | user?.disposeStream(); 322 | users.remove(user); 323 | update(); 324 | } 325 | 326 | void leaveRoom() async { 327 | debugPrint(users.length.toString()); 328 | _signalServer.sendString(const JsonEncoder().convert({ 329 | 'event': 'leave', 330 | })); 331 | // users.map((e) { 332 | // debugPrint("del ${e.id}"); 333 | // users.firstWhere((user) => user.id == e.id) 334 | // ..display = false 335 | // ..disposeStream(); 336 | // }).toSet(); 337 | for (var e in users) { 338 | debugPrint("del ${e.id}"); 339 | MeetingUser user = users.firstWhere((user) => user.id == e.id); 340 | user.display = false; 341 | user.disposeStream(); 342 | } 343 | _streams.forEach((key, value) { 344 | value.dispose(); 345 | }); 346 | users.clear(); 347 | _peerConnection.dispose(); 348 | _signalServer.close(); 349 | } 350 | 351 | // void pushStream(MediaStream stream) { 352 | // stream.getTracks().forEach((track) async { 353 | // await _peerConnection.addTrack(track, stream); 354 | // }); 355 | // } 356 | 357 | // @override 358 | // void dispose() { 359 | // _peerConnection.dispose(); 360 | // users.map((e) => leave(e.id)).toSet(); 361 | // _signalServer.close(); 362 | // super.dispose(); 363 | // } 364 | } 365 | -------------------------------------------------------------------------------- /client/geek_meeting/lib/components/dialog.dart.bak: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/cupertino.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:geek_meeting/models/create_meeting.dart'; 6 | import 'package:geek_meeting/models/home.dart'; 7 | import 'package:geek_meeting/route/routes_path.dart'; 8 | import 'package:geek_meeting/utils/common.dart'; 9 | import 'package:geek_meeting/utils/global.dart'; 10 | import 'package:get/get.dart'; 11 | 12 | // 用户协议Dialog 13 | Future showMyDiaLog(BuildContext context, String title, String text) { 14 | return showDialog( 15 | context: context, 16 | builder: (context) { 17 | return SimpleDialog( 18 | children: [ 19 | SizedBox( 20 | width: MediaQuery.of(context).size.height / 2, 21 | height: MediaQuery.of(context).size.height / 3, 22 | child: Scrollbar( 23 | child: SingleChildScrollView( 24 | padding: const EdgeInsets.all(20), 25 | child: Text(text), 26 | ), 27 | ), 28 | ), 29 | Row( 30 | mainAxisAlignment: MainAxisAlignment.end, 31 | children: [ 32 | InkWell( 33 | child: Padding( 34 | padding: const EdgeInsets.all(20), 35 | child: Text( 36 | "确定", 37 | style: TextStyle(color: Colors.blue[300], fontSize: 15), 38 | ), 39 | ), 40 | onTap: () => {Navigator.pop(context)}, 41 | ), 42 | ], 43 | ) 44 | ], 45 | ); 46 | }, 47 | ); 48 | } 49 | 50 | // 加入会议Dialog 51 | Future showJoinRoom(BuildContext context) { 52 | final home = HomeModel(); 53 | final TextEditingController _roomId = TextEditingController(); 54 | final TextEditingController _userName = TextEditingController(); 55 | final TextEditingController _passWord = TextEditingController(); 56 | return showDialog( 57 | context: context, 58 | builder: (context) { 59 | return SimpleDialog( 60 | children: [ 61 | Center( 62 | child: Container( 63 | width: 300, 64 | height: 330, 65 | child: Padding( 66 | padding: const EdgeInsets.all(20), 67 | child: Column( 68 | children: [ 69 | SizedBox( 70 | height: 240, 71 | child: GetBuilder( 72 | init: home, 73 | builder: (_) { 74 | return Column( 75 | children: [ 76 | TextField( 77 | controller: _userName, 78 | maxLength: 10, 79 | onTap: () => {home.nameErr = null}, 80 | decoration: InputDecoration( 81 | icon: const Icon(Icons.person_pin), 82 | labelText: "请输入昵称", 83 | errorText: home.nameErr 84 | // suffix: ElevatedButton( 85 | // onPressed: () {}, 86 | // child: const Icon(Icons.send_to_mobile), 87 | // ), 88 | ), 89 | ), 90 | TextField( 91 | controller: _roomId, 92 | maxLength: 6, 93 | keyboardType: TextInputType.number, 94 | onTap: () => {home.errType = null}, 95 | decoration: InputDecoration( 96 | icon: 97 | const Icon(Icons.meeting_room_rounded), 98 | labelText: "请输入房间号", 99 | prefixText: "MEETING - ", 100 | errorText: home.errMsg 101 | // suffix: ElevatedButton( 102 | // onPressed: () {}, 103 | // child: const Icon(Icons.send_to_mobile), 104 | // ), 105 | ), 106 | ), 107 | TextField( 108 | controller: _passWord, 109 | obscureText: true, 110 | maxLength: 6, 111 | onTap: () => {home.passWordErr = false}, 112 | decoration: InputDecoration( 113 | icon: const Icon(Icons.keyboard_outlined), 114 | labelText: "会议密码,没有则留空。", 115 | errorText: 116 | home.passWordErr ? "会议密码错误!" : null, 117 | // suffix: ElevatedButton( 118 | // onPressed: () {}, 119 | // child: const Icon(Icons.send_to_mobile), 120 | // ), 121 | ), 122 | ), 123 | ], 124 | ); 125 | }, 126 | ), 127 | ), 128 | Center( 129 | child: ElevatedButton( 130 | onPressed: () { 131 | if (_userName.text.trim().isEmpty) { 132 | home.nameErr = "昵称不能为空!"; 133 | return; 134 | } 135 | if (_roomId.text.isEmpty) { 136 | home.errType = err.roomIdErr; 137 | return; 138 | } 139 | Get.toNamed(RoutesPath.Meeting, parameters: { 140 | "name": _userName.text, 141 | "roomId": _roomId.text 142 | }); 143 | }, 144 | child: const Icon( 145 | Icons.keyboard_arrow_right_rounded, 146 | size: 45, 147 | ), 148 | style: ButtonStyle( 149 | shape: MaterialStateProperty.all( 150 | const CircleBorder(), 151 | ), 152 | ), 153 | ), 154 | ), 155 | ], 156 | ), 157 | ), 158 | decoration: const BoxDecoration( 159 | color: Colors.white, 160 | borderRadius: BorderRadius.all(Radius.circular(20)), 161 | ), 162 | ), 163 | ), 164 | ], 165 | ); 166 | }, 167 | ); 168 | } 169 | 170 | // 创建会议Dialog 171 | Future showCreateRoom(BuildContext context) { 172 | final createMeeting = CreateMeetingModel(); 173 | final TextEditingController _passwd = TextEditingController(); 174 | return showDialog( 175 | context: context, 176 | builder: (context) { 177 | return SimpleDialog( 178 | children: [ 179 | Center( 180 | child: Container( 181 | width: 300, 182 | height: 330, 183 | child: Padding( 184 | padding: const EdgeInsets.all(20), 185 | child: Column( 186 | children: [ 187 | SizedBox( 188 | height: 240, 189 | child: GetBuilder( 190 | init: createMeeting, 191 | builder: (_) { 192 | return Column( 193 | children: [ 194 | // Text(fomatDate(createMeeting.startTime)), 195 | // Text(fomatDate(createMeeting.endTime)), 196 | const Text("创建会议"), 197 | TextField( 198 | readOnly: true, 199 | onTap: () async { 200 | DateTime? startTime = 201 | await selectTime(context); 202 | if (startTime != null) { 203 | createMeeting.startTime = startTime; 204 | } 205 | }, 206 | keyboardType: TextInputType.datetime, 207 | decoration: InputDecoration( 208 | icon: const Icon(Icons.access_time), 209 | hintText: fomatDate(createMeeting.startTime), 210 | helperText: "开始时间", 211 | ), 212 | ), 213 | TextField( 214 | readOnly: true, 215 | onTap: () async { 216 | DateTime? endTime = await selectTime(context, 217 | startTime: createMeeting.startTime); 218 | if (endTime != null && 219 | endTime 220 | .isAfter(createMeeting.startTime)) { 221 | createMeeting.endTime = endTime; 222 | } 223 | }, 224 | decoration: InputDecoration( 225 | icon: const Icon(Icons.access_time), 226 | hintText: fomatDate(createMeeting.endTime), 227 | helperText: "结束时间", 228 | ), 229 | ), 230 | TextField( 231 | controller: _passwd, 232 | maxLength: 6, 233 | onTap: () async {}, 234 | decoration: const InputDecoration( 235 | icon: Icon(Icons.vpn_key_outlined), 236 | hintText: "会议密码", 237 | helperText: "会议密码,留空则不启用。", 238 | ), 239 | ), 240 | ], 241 | ); 242 | }, 243 | ), 244 | ), 245 | const SizedBox( 246 | height: 20, 247 | ), 248 | Center( 249 | child: ElevatedButton( 250 | onPressed: () { 251 | // debugPrint( 252 | // "start time:${createMeeting.startTime.formatNet()}\r\nend time:${createMeeting.endTime.formatNet()}\r\npasswd:${_passwd.text}"); 253 | NetUtil.net.post("/create_meeting", data: { 254 | "start_time": createMeeting.startTime.formatNet(), 255 | "end_time": createMeeting.endTime.formatNet(), 256 | "password": _passwd.text 257 | }, success: (data) { 258 | Map result = jsonDecode(data); 259 | if (result["code"] == 200) { 260 | debugPrint(result["data"].toString()); 261 | } else { 262 | Get.snackbar("系统提示", result["msg"]); 263 | } 264 | }); 265 | }, 266 | child: const Text("创建会议"), 267 | ), 268 | ), 269 | ], 270 | ), 271 | ), 272 | decoration: const BoxDecoration( 273 | color: Colors.white, 274 | borderRadius: BorderRadius.all(Radius.circular(20)), 275 | ), 276 | ), 277 | ), 278 | ], 279 | ); 280 | }, 281 | ); 282 | } 283 | 284 | Future selectTime(BuildContext context, 285 | {DateTime? startTime}) async { 286 | DateTime? date = await showDatePicker( 287 | context: context, 288 | locale: const Locale('zh'), 289 | initialDate: startTime ?? DateTime.now(), 290 | firstDate: startTime ?? DateTime.now(), 291 | lastDate: startTime ?? DateTime.now().add(const Duration(days: 7)), 292 | cancelText: "取消", 293 | helpText: "选择日期", 294 | confirmText: "选择时间", 295 | initialEntryMode: DatePickerEntryMode.calendarOnly, 296 | ); 297 | if (date == null) return null; 298 | TimeOfDay? time = await showTimePicker( 299 | context: context, 300 | helpText: "选择时间", 301 | initialTime: startTime == null 302 | ? TimeOfDay.now() 303 | : TimeOfDay(hour: startTime.hour, minute: startTime.minute), 304 | ); 305 | if (time == null) return null; 306 | DateTime meetinDate = DateTime( 307 | date.year, 308 | date.month, 309 | date.day, 310 | time.hour, 311 | time.minute, 312 | ); 313 | return meetinDate; 314 | } 315 | --------------------------------------------------------------------------------