From a0c5e7590d5111576e37e32978e35a9cdc4d59b5 Mon Sep 17 00:00:00 2001 From: CGH0S7 <776459475@qq.com> Date: Wed, 25 Mar 2026 11:48:39 +0800 Subject: [PATCH] feat: implement chat history, theme customization, and password management - Added chat history persistence for active rooms with auto-cleanup on stream end. - Overhauled Settings page with user profile, theme color picker, and password change. - Added backend API for user password updates. - Integrated flutter_launcher_icons and updated app icon to 'H' logo. - Fixed 'Duplicate keys' bug in danmaku by using UniqueKey and filtering historical messages. - Updated version to 1.0.0-beta3.5 and author info. --- backend/internal/api/auth.go | 42 ++++ backend/internal/api/router.go | 1 + backend/internal/chat/hub.go | 67 +++-- backend/internal/stream/server.go | 5 + .../android/app/src/main/AndroidManifest.xml | 2 +- .../main/res/mipmap-hdpi/launcher_icon.png | Bin 0 -> 948 bytes .../main/res/mipmap-mdpi/launcher_icon.png | Bin 0 -> 681 bytes .../main/res/mipmap-xhdpi/launcher_icon.png | Bin 0 -> 882 bytes .../main/res/mipmap-xxhdpi/launcher_icon.png | Bin 0 -> 1361 bytes .../main/res/mipmap-xxxhdpi/launcher_icon.png | Bin 0 -> 1203 bytes frontend/assets/icon/app_icon.png | Bin 0 -> 9068 bytes frontend/lib/main.dart | 6 +- frontend/lib/pages/player_page.dart | 10 +- frontend/lib/pages/settings_page.dart | 230 ++++++++++++++++-- frontend/lib/providers/settings_provider.dart | 18 +- frontend/lib/services/api_service.dart | 8 + frontend/lib/services/chat_service.dart | 11 +- frontend/pubspec.lock | 80 ++++++ frontend/pubspec.yaml | 18 +- frontend/test/widget_test.dart | 2 +- .../windows/runner/resources/app_icon.ico | Bin 33772 -> 1517 bytes 21 files changed, 446 insertions(+), 54 deletions(-) create mode 100644 frontend/android/app/src/main/res/mipmap-hdpi/launcher_icon.png create mode 100644 frontend/android/app/src/main/res/mipmap-mdpi/launcher_icon.png create mode 100644 frontend/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png create mode 100644 frontend/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png create mode 100644 frontend/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png create mode 100644 frontend/assets/icon/app_icon.png diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index 5eabc06..9fec6b8 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -20,6 +20,11 @@ type LoginRequest struct { Password string `json:"password" binding:"required"` } +type ChangePasswordRequest struct { + OldPassword string `json:"old_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required"` +} + func Register(c *gin.Context) { var req RegisterRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -95,3 +100,40 @@ func Login(c *gin.Context) { "username": user.Username, }) } + +func ChangePassword(c *gin.Context) { + userID, _ := c.Get("user_id") + + var req ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var user model.User + if err := db.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + // Verify old password + if !utils.CheckPasswordHash(req.OldPassword, user.Password) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid old password"}) + return + } + + // Hash new password + hashedPassword, err := utils.HashPassword(req.NewPassword) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) + return + } + + // Update user + if err := db.DB.Model(&user).Update("password", hashedPassword).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update password"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"}) +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 2bb1273..5039e10 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -27,6 +27,7 @@ func SetupRouter() *gin.Engine { authGroup.Use(AuthMiddleware()) { authGroup.GET("/room/my", GetMyRoom) + authGroup.POST("/user/change-password", ChangePassword) } return r diff --git a/backend/internal/chat/hub.go b/backend/internal/chat/hub.go index 3d4335b..e99d1b7 100644 --- a/backend/internal/chat/hub.go +++ b/backend/internal/chat/hub.go @@ -16,10 +16,11 @@ const ( ) type Message struct { - Type string `json:"type"` // "chat", "system", "danmaku" - Username string `json:"username"` - Content string `json:"content"` - RoomID string `json:"room_id"` + Type string `json:"type"` // "chat", "system", "danmaku" + Username string `json:"username"` + Content string `json:"content"` + RoomID string `json:"room_id"` + IsHistory bool `json:"is_history"` } type Client struct { @@ -31,19 +32,21 @@ type Client struct { } type Hub struct { - rooms map[string]map[*Client]bool - broadcast chan Message - register chan *Client - unregister chan *Client - mutex sync.RWMutex + rooms map[string]map[*Client]bool + roomsHistory map[string][]Message + broadcast chan Message + register chan *Client + unregister chan *Client + mutex sync.RWMutex } func NewHub() *Hub { return &Hub{ - broadcast: make(chan Message), - register: make(chan *Client), - unregister: make(chan *Client), - rooms: make(map[string]map[*Client]bool), + broadcast: make(chan Message), + register: make(chan *Client), + unregister: make(chan *Client), + rooms: make(map[string]map[*Client]bool), + roomsHistory: make(map[string][]Message), } } @@ -56,6 +59,20 @@ func (h *Hub) Run() { h.rooms[client.RoomID] = make(map[*Client]bool) } h.rooms[client.RoomID][client] = true + + // Send existing history to the newly joined client + if history, ok := h.roomsHistory[client.RoomID]; ok { + for _, msg := range history { + msg.IsHistory = true + msgBytes, _ := json.Marshal(msg) + // Use select to avoid blocking if client's send channel is full + select { + case client.Send <- msgBytes: + default: + // If send fails, we could potentially log or ignore + } + } + } h.mutex.Unlock() case client := <-h.unregister: @@ -64,6 +81,10 @@ func (h *Hub) Run() { if _, ok := rooms[client]; ok { delete(rooms, client) close(client.Send) + // We no longer delete the room from h.rooms here if we want history to persist + // even if everyone leaves (as long as it's active in DB). + // But we should clean up if the room is empty and we want to save memory. + // However, the history is what matters. if len(rooms) == 0 { delete(h.rooms, client.RoomID) } @@ -72,7 +93,16 @@ func (h *Hub) Run() { h.mutex.Unlock() case message := <-h.broadcast: - h.mutex.RLock() + h.mutex.Lock() + // Only store "chat" and "danmaku" messages in history + if message.Type == "chat" || message.Type == "danmaku" { + h.roomsHistory[message.RoomID] = append(h.roomsHistory[message.RoomID], message) + // Limit history size to avoid memory leak (e.g., last 100 messages) + if len(h.roomsHistory[message.RoomID]) > 100 { + h.roomsHistory[message.RoomID] = h.roomsHistory[message.RoomID][1:] + } + } + clients := h.rooms[message.RoomID] if clients != nil { msgBytes, _ := json.Marshal(message) @@ -85,11 +115,18 @@ func (h *Hub) Run() { } } } - h.mutex.RUnlock() + h.mutex.Unlock() } } } +// ClearRoomHistory removes history for a room, should be called when stream ends +func (h *Hub) ClearRoomHistory(roomID string) { + h.mutex.Lock() + defer h.mutex.Unlock() + delete(h.roomsHistory, roomID) +} + func (h *Hub) RegisterClient(c *Client) { h.register <- c } diff --git a/backend/internal/stream/server.go b/backend/internal/stream/server.go index e594036..7fa7a3f 100644 --- a/backend/internal/stream/server.go +++ b/backend/internal/stream/server.go @@ -11,6 +11,7 @@ import ( "github.com/nareix/joy4/format" "github.com/nareix/joy4/format/rtmp" + "hightube/internal/chat" "hightube/internal/db" "hightube/internal/model" ) @@ -83,6 +84,10 @@ func NewRTMPServer() *RTMPServer { q.Close() // Explicitly set is_active to false using map db.DB.Model(&room).Updates(map[string]interface{}{"is_active": false}) + + // Clear chat history for this room + chat.MainHub.ClearRoomHistory(fmt.Sprintf("%d", room.ID)) + fmt.Printf("[INFO] Publishing ended for Room ID: %d\n", room.ID) }() diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml index c561285..0a3bb1e 100644 --- a/frontend/android/app/src/main/AndroidManifest.xml +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ + android:icon="@mipmap/launcher_icon"> trp9G8Z_WGlw{_n7MCRE z7U0&SJ3+FNfr06%r;B4q#hkY@>@$KBMUK7~pJgwW9iZ7I>Zzv9r^$C#fvZfUWQ9_Y zt4Y+VX-Z|`0!#d?*aTlIbT#uysc&2-mLzEG(HYt0=c=Wq_-uXcC7YG&J>#~V6t7`F zryp?W@4b_MUu3@DnHz2((VJ#`Nh0)b)}P|JtNK>&%3(RO@PdIshJ?hj#8oei%a(`y zWuKZkV{!5{t?aqVf;>`eY{oo3M|qfo3sssN4h;TqKmDI(M0G8?yW#V_?^$Iw z``<6EKKCZ|n)tk>_a&S!PZwG_>0Vpz!z)V85COkZPvRh5{WOY}*n%1{3 z+E(tm>DBdrHl2MY9F=nXaPQr9udi+o-%*nBxbQ{Bf$b_vjaP#n%U;@-S8k=Hblj?@ zHur#pn~#9;ny`yMLY8+)ehUwcZM(~PGrkKC4h)Mf-U5@m=mAXR7Ahj^CHj z;~nI(sVaYlv|f5|r+D(piKk}u#qzopr0Q%#Qi~s-t literal 0 HcmV?d00001 diff --git a/frontend/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/frontend/android/app/src/main/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..46cd70fbfb2b403cfa8d1327eaa9a42459945c85 GIT binary patch literal 681 zcmeAS@N?(olHy`uVBq!ia0vp^1|TfJ1SC5?Y?=(DR7+eVN>UO_QmvAUQh^kMk%5t! zu7R|lFd_1)tp?pYsC`B_})H^KjgmI`0&HeR%c`OeSRBz zU+U)5SMT@l75&2bbGi7Bw-Ou)3Jr}6Y)t5k%gj^xcAa~=^Mu~c>(@jyDt|0qc~9|v zd};XoEr-@@Nw>6qsJH6!GMjI!a&H|7x$4*xmGb4<|Cw?tcCX)YVd6BC$8R>s>aG3t zx{33i;{1S8zwF4G`DSI+6Go^6Hy|-IFZBBgH2~>J3cb^9LyYCg^ zHvU(Csg=wTQN5LJ^yi~^P|wBNwP!9lT>n2sWTN@|kGw0~f2rL$;nlp?;>(VtH+l1c zy5Tm07ztGiu3ou3E1~$#(&V$xZr|kc(4YO#=k_`dpZz-Q`lkB}VvlM~jrw)_eduZl z^St}tefY1unE9e=hJc)*RMlDj{HW(oa+-_u)sC*X!Lig()}IGQn1VvNg7wsrO0`nU S@Dsq4!QkoY=d#Wzp$PyXx)Zkm literal 0 HcmV?d00001 diff --git a/frontend/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/frontend/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5f709987d9b0eba68541e3f8879d67b5f3fe83b1 GIT binary patch literal 882 zcmeAS@N?(olHy`uVBq!ia0vp^2_P)M1SD;YKdz^NlIc#s#S7PDv)9@GB7gJ zH89pSG7T{>vNE-_GO*M(Ft9Q(;J(oyg`y!hKP5A*61Rq`R*Pjo4H|G8N-}d(i%Sx7 z3vlbvogi7sz`(TF)5S5QV$Rz;wi&^J673%+&t9GTRv^b`mg9wn!)pVy$~H!nUeZ%? z?OyaiDWYUz#08$#vI`tyAp&nh0v!c(v^qMX4+gYqMI7aC=_=_Ed*r@QDP*$7jyU!I zFL&NK{W<#n!&-iu&xM9t#U6qob3<1OWjRvYDPQn5|4Ve@%FN z%XDq$yBnqdzx+Er^YPUD?PmM`eq4S1Uv9Pgt$ljC_kMn{tFU}({jY*ad-e=hHQ z*_OTWbJ}*pW%vK(eii!FAfUt4)Y71!sNliDA%u&m@`zD&zWKDIb>SX+sv1jSz7+S{kEgRVY0gS-mB*6`u3VSa+~(s?oSR5KYaDu$ti_z^tS`; zOotVqTG^XYh3Ob6Mw<&;$TO8%gW{ literal 0 HcmV?d00001 diff --git a/frontend/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/frontend/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ecad54c2456ff7108f206bb41bf0583a109a2b7c GIT binary patch literal 1361 zcmeAS@N?(olHy`uVBq!ia0vp^6F^vi2}oLOS-A#Csg}4#l%ynu-3gMF3=Awyo-U3d6?5L+w$GT8D#QNady%4xh^VNzbatVTYi!7t$y^J)#hLs5 zN$;|ZkkMMim+2_5L_w{aBdTD6mne_Lsw+!fT`q+Q2<8fI;p7s`l=K+67UKdo)@B7%wBT%$ zY1!1NlP0}N+MM8QyK3?zsk>9Ol~^<-(2=Bxw+7Vtj|ajhf48kUyEfmXKkswV;?I}g{VB2e`1N@3>iBtjdV6==d%yOdJO942 z)68dW)z?|?C@;!;y>r{IFL#cLM{PboCrV@8m-5BRt_Kx74l0~jurn&_^QkSxNxQ>W z@AZfw~zT(`imD9NHAN)7*<#RrF zt*2caj$CZ$i0$mvAhxq#!{qiA^J=7Ch|vY&dE~3VmRv5sud*~}+s~BbtUm=OlbwIu ztbYE-H@sI?yYEWO+#s%N$>;s=?l&=v^$~S7-22%1t6suiue;I7p;|!(9B>5o%Gm`w zfB*gy4|Jb3h`-7vurpjU z{D@rj%d_YA|NOlCvbf&d$oeH;4lewCe&hdjw(ooQ*DSJ#@VOQoJD>L7&IP2~6^5hkc|Lvmz0A2fU5{@6d%MwGPbR|d_OBN&f3_B{ZeLZC^uOxoshsCexBuF9RB%Z` ztj@npUmRao7zTej@lKE5`T6$Kt53g`wx7K-V$J@0KV82UuM7)EP6CiTzopr078!^T>t<8 literal 0 HcmV?d00001 diff --git a/frontend/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/frontend/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a4a4e5ac08d2d2eeeac9eea3ec767da3c84267f3 GIT binary patch literal 1203 zcmeAS@N?(olHy`uVBq!ia0vp^2S8YW2}t(psS5)s)e_f;l9a@fRIB8oR3OD*WME{b zYhbKvWEx^%WMyh;WnigoU|?lnztrp9G8Z_WGlw{_n7MCRE z7U0&SJ3+FNfr0snr;B4q#hkZStTXPV%CtSKPs$1n@DN>nf$fsg6*2L`j{!#xKX{@P z(j~$!UN}XEOIC)9&reoJfT`=&!rk57LaQ`1W~j!ph-zDPbm+=mdeYsz<9qh3JDhRX z|5aC3v{z@9mDTg#IzYW}VKz5iD6 zzh7}XEPg$Gd-1t=n7-~h-McSJW3(B&Y&ZYEus7I#--e<$b65T>%)h?eoR2?V$4~$K z+Pt$nWOMGDZ{KJ2@p-m3fBfFcH}$`-?<^0W7hzrTu-9AtTHY6nbu(!nEM*%zHy=KJ z_^$eW`(JP8J*_%jw)^wE+it?_uSIjFF}zKhyj(4ZJ$etsVJw$)#S%$(_-9yH2+EnND!zP7$9LaI$WS>B(&`0z&EnOh&% zet(po?tYw~mzz8M*#GVS=Sb+?yAaspOLrGilwF?5Z~2|zsk&?1bYL05;OXk;vd$@? F2>_kOgSG$w literal 0 HcmV?d00001 diff --git a/frontend/assets/icon/app_icon.png b/frontend/assets/icon/app_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5337445936581413e4327ca379621af6e8c5b1f6 GIT binary patch literal 9068 zcmeAS@N?(olHy`uVBq!ia0y~yU;#2&7zCJr)Pna3(}9#ivPY0F14ES>14Ba#1H&(% zP{RubhEf9thF1v;3|2E37{m+a>!C8<`)MX5lF!N|bKOxM6z*T^))z{twf(#pV6+rYrez<~QkgA|H} z-29Zxv`X9>s#-0U0X1mAZ79jiO)V}-%q_sJM|Xl`B?E(!kEe@cNX4zUcMMlwOO-kP z@PF|+XPJ*Yxt!@8?n*}D&aJm5I~8r%tH=?=k;y68^g%R(gI%WS#KD6r*G@VhptS1f z!DT|6E>T(*oLX}I`Hv2q{rqxQ+`;nQ?fmdkc5eOVkoO;0sW*vAZ45k?r zJRo#~0z`tL2^0%Jz##;oM-_~Q!)SV77|j=>=S^LAb?Z5Z))AjD}{Q3K>s<;mrS^oLCk3XzF?sxLP zvu_q(jON;Q|Nc(PzyB_0e;4!rC%2cCLBbPQzWm>+f6ulq>-$321P!0a_J7OYZyUR-?)N)>f4{zaOdEXe)jU6X`?GfT z{qx`bpS$HVGQ1D~I=k%b{^N3SH!6RgIru)$rl#(TSVL;j=i}e~-o@UWaV`HWur6od z_{a)U3LN<21XBzGDiGRX0)%F81dWFQ!Ki}KZ~%qCXnKI8gVAsp4F_NdjFyv-WI#qZ zoE7Undv@*Gi^gCR8ty)U)-;2nc{JKo1;@&0TtJfdXgG|9126(); + final settings = context.watch(); return MaterialApp( title: 'Hightube', - // 设置深色主题为主,更符合视频类应用的审美 theme: ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed( - seedColor: Colors.deepPurple, + seedColor: settings.themeColor, brightness: Brightness.light, ), ), darkTheme: ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed( - seedColor: Colors.deepPurple, + seedColor: settings.themeColor, brightness: Brightness.dark, ), ), diff --git a/frontend/lib/pages/player_page.dart b/frontend/lib/pages/player_page.dart index e8c3f5b..91c7cec 100644 --- a/frontend/lib/pages/player_page.dart +++ b/frontend/lib/pages/player_page.dart @@ -62,7 +62,7 @@ class _PlayerPageState extends State { if (mounted) { setState(() { _messages.insert(0, msg); - if (msg.type == "chat" || msg.type == "danmaku") { + if (!msg.isHistory && (msg.type == "chat" || msg.type == "danmaku")) { _addDanmaku(msg.content); } }); @@ -71,15 +71,15 @@ class _PlayerPageState extends State { } void _addDanmaku(String text) { - final id = DateTime.now().millisecondsSinceEpoch; - final top = 20.0 + (id % 6) * 30.0; + final key = UniqueKey(); + final top = 20.0 + (DateTime.now().millisecondsSinceEpoch % 6) * 30.0; final danmaku = _DanmakuItem( - key: ValueKey(id), + key: key, text: text, top: top, onFinished: () { - if (mounted) setState(() => _danmakus.removeWhere((w) => w.key == ValueKey(id))); + if (mounted) setState(() => _danmakus.removeWhere((w) => w.key == key)); }, ); diff --git a/frontend/lib/pages/settings_page.dart b/frontend/lib/pages/settings_page.dart index 1ff9c62..66662e2 100644 --- a/frontend/lib/pages/settings_page.dart +++ b/frontend/lib/pages/settings_page.dart @@ -1,6 +1,9 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/settings_provider.dart'; +import '../providers/auth_provider.dart'; +import '../services/api_service.dart'; class SettingsPage extends StatefulWidget { @override @@ -9,6 +12,18 @@ class SettingsPage extends StatefulWidget { class _SettingsPageState extends State { late TextEditingController _urlController; + final TextEditingController _oldPasswordController = TextEditingController(); + final TextEditingController _newPasswordController = TextEditingController(); + + final List _availableColors = [ + Colors.blue, + Colors.deepPurple, + Colors.red, + Colors.green, + Colors.orange, + Colors.teal, + Colors.pink, + ]; @override void initState() { @@ -16,22 +31,60 @@ class _SettingsPageState extends State { _urlController = TextEditingController(text: context.read().baseUrl); } + @override + void dispose() { + _urlController.dispose(); + _oldPasswordController.dispose(); + _newPasswordController.dispose(); + super.dispose(); + } + + void _handleChangePassword() async { + final settings = context.read(); + final auth = context.read(); + final api = ApiService(settings, auth.token); + + if (_oldPasswordController.text.isEmpty || _newPasswordController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Please fill in both password fields"))); + return; + } + + try { + final resp = await api.changePassword(_oldPasswordController.text, _newPasswordController.text); + final data = jsonDecode(resp.body); + if (resp.statusCode == 200) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Password updated successfully"))); + _oldPasswordController.clear(); + _newPasswordController.clear(); + } else { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Error: ${data['error']}"))); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Failed to connect to server"))); + } + } + @override Widget build(BuildContext context) { + final auth = context.watch(); + final settings = context.watch(); + return Scaffold( - appBar: AppBar(title: Text("Settings", style: TextStyle(fontWeight: FontWeight.bold))), + appBar: AppBar( + title: Text("Settings", style: TextStyle(fontWeight: FontWeight.bold)), + centerTitle: true, + ), body: SingleChildScrollView( padding: const EdgeInsets.all(24.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - "Network Configuration", - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), + // User Profile Section + _buildProfileSection(auth), + SizedBox(height: 32), + + // Network Configuration + _buildSectionTitle("Network Configuration"), SizedBox(height: 16), TextField( controller: _urlController, @@ -40,43 +93,172 @@ class _SettingsPageState extends State { hintText: "http://127.0.0.1:8080", prefixIcon: Icon(Icons.lan), border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - helperText: "Restarting stream may be required after change", ), ), - SizedBox(height: 24), + SizedBox(height: 12), SizedBox( width: double.infinity, - height: 50, child: ElevatedButton.icon( onPressed: () { context.read().setBaseUrl(_urlController.text); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text("Server URL Updated"), - behavior: SnackBarBehavior.floating, - ), - ); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Server URL Updated"), behavior: SnackBarBehavior.floating)); }, icon: Icon(Icons.save), - label: Text("Save Configuration"), + label: Text("Save Network Settings"), style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + SizedBox(height: 32), + + // Theme Color Section + _buildSectionTitle("Theme Customization"), + SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: _availableColors.map((color) { + bool isSelected = settings.themeColor.value == color.value; + return GestureDetector( + onTap: () => settings.setThemeColor(color), + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: isSelected ? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3) : null, + boxShadow: [ + BoxShadow(color: Colors.black26, blurRadius: 4, offset: Offset(0, 2)), + ], + ), + child: isSelected ? Icon(Icons.check, color: Colors.white) : null, + ), + ); + }).toList(), + ), + SizedBox(height: 32), + + // Security Section + _buildSectionTitle("Security"), + SizedBox(height: 16), + TextField( + controller: _oldPasswordController, + obscureText: true, + decoration: InputDecoration( + labelText: "Old Password", + prefixIcon: Icon(Icons.lock_outline), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + SizedBox(height: 12), + TextField( + controller: _newPasswordController, + obscureText: true, + decoration: InputDecoration( + labelText: "New Password", + prefixIcon: Icon(Icons.lock_reset), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _handleChangePassword, + icon: Icon(Icons.update), + label: Text("Change Password"), + style: OutlinedButton.styleFrom( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ), ), SizedBox(height: 40), + + // About Section Divider(), SizedBox(height: 20), - Text( - "About Hightube", - style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey), + Center( + child: Column( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: settings.themeColor, + borderRadius: BorderRadius.circular(12), + ), + child: Center(child: Text("H", style: TextStyle(color: Colors.white, fontSize: 32, fontWeight: FontWeight.bold))), + ), + SizedBox(height: 12), + Text("Hightube", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Text("Version: 1.0.0-beta3.5", style: TextStyle(color: Colors.grey)), + Text("Author: Highground-Soft & Minimax", style: TextStyle(color: Colors.grey)), + SizedBox(height: 20), + Text("© 2026 Hightube Project", style: TextStyle(fontSize: 12, color: Colors.grey)), + ], + ), ), - SizedBox(height: 10), - Text("Version: 1.0.0-MVP"), - Text("Status: Phase 3.5 (UI Refinement)"), + SizedBox(height: 40), ], ), ), ); } + + Widget _buildSectionTitle(String title) { + return Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ); + } + + Widget _buildProfileSection(AuthProvider auth) { + return Container( + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + CircleAvatar( + radius: 35, + backgroundColor: Theme.of(context).colorScheme.primary, + child: Text( + (auth.username ?? "U")[0].toUpperCase(), + style: TextStyle(fontSize: 32, color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + auth.username ?? "Unknown User", + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + ), + Text( + "Self-hosted Streamer", + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ], + ), + ), + IconButton( + onPressed: () => auth.logout(), + icon: Icon(Icons.logout, color: Colors.redAccent), + tooltip: "Logout", + ), + ], + ), + ); + } } diff --git a/frontend/lib/providers/settings_provider.dart b/frontend/lib/providers/settings_provider.dart index 9a3c01d..7ad4cd5 100644 --- a/frontend/lib/providers/settings_provider.dart +++ b/frontend/lib/providers/settings_provider.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; - class SettingsProvider with ChangeNotifier { // Default server address for local development. // Using 10.0.2.2 for Android emulator or localhost for Desktop. String _baseUrl = "http://localhost:8080"; - + Color _themeColor = Colors.blue; + String get baseUrl => _baseUrl; + Color get themeColor => _themeColor; SettingsProvider() { _loadSettings(); @@ -15,6 +16,10 @@ class SettingsProvider with ChangeNotifier { void _loadSettings() async { final prefs = await SharedPreferences.getInstance(); _baseUrl = prefs.getString('baseUrl') ?? _baseUrl; + final colorValue = prefs.getInt('themeColor'); + if (colorValue != null) { + _themeColor = Color(colorValue); + } notifyListeners(); } @@ -24,7 +29,14 @@ class SettingsProvider with ChangeNotifier { await prefs.setString('baseUrl', url); notifyListeners(); } - + + void setThemeColor(Color color) async { + _themeColor = color; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('themeColor', color.value); + notifyListeners(); + } + // Also provide the RTMP URL based on the same hostname String get rtmpUrl { final uri = Uri.parse(_baseUrl); diff --git a/frontend/lib/services/api_service.dart b/frontend/lib/services/api_service.dart index 2dbd5b4..640a80d 100644 --- a/frontend/lib/services/api_service.dart +++ b/frontend/lib/services/api_service.dart @@ -42,4 +42,12 @@ class ApiService { headers: _headers, ); } + + Future changePassword(String oldPassword, String newPassword) async { + return await http.post( + Uri.parse("${settings.baseUrl}/api/user/change-password"), + headers: _headers, + body: jsonEncode({"old_password": oldPassword, "new_password": newPassword}), + ); + } } diff --git a/frontend/lib/services/chat_service.dart b/frontend/lib/services/chat_service.dart index 320a6e9..5bcd837 100644 --- a/frontend/lib/services/chat_service.dart +++ b/frontend/lib/services/chat_service.dart @@ -7,8 +7,15 @@ class ChatMessage { final String username; final String content; final String roomId; + final bool isHistory; - ChatMessage({required this.type, required this.username, required this.content, required this.roomId}); + ChatMessage({ + required this.type, + required this.username, + required this.content, + required this.roomId, + this.isHistory = false, + }); factory ChatMessage.fromJson(Map json) { return ChatMessage( @@ -16,6 +23,7 @@ class ChatMessage { username: json['username'] ?? 'Anonymous', content: json['content'] ?? '', roomId: json['room_id'] ?? '', + isHistory: json['is_history'] ?? false, ); } @@ -24,6 +32,7 @@ class ChatMessage { 'username': username, 'content': content, 'room_id': roomId, + 'is_history': isHistory, }; } diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index ff4ed74..0517524 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -25,6 +41,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -102,6 +134,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" flutter_lints: dependency: "direct dev" description: @@ -168,6 +208,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" leak_tracker: dependency: transitive description: @@ -312,6 +368,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -328,6 +392,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" provider: dependency: "direct main" description: @@ -549,6 +621,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index cde0b8a..9f7294d 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 1.0.0-beta3.5 environment: sdk: ^3.11.1 @@ -44,6 +44,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + flutter_launcher_icons: ^0.13.1 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is @@ -52,6 +53,21 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^6.0.0 +flutter_launcher_icons: + android: "launcher_icon" + ios: false + image_path: "assets/icon/app_icon.png" + min_sdk_android: 21 + web: + generate: true + image_path: "assets/icon/app_icon.png" + background_color: "#ffffff" + theme_color: "#2196F3" + windows: + generate: true + image_path: "assets/icon/app_icon.png" + icon_size: 256 + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/frontend/test/widget_test.dart b/frontend/test/widget_test.dart index 00e8ab7..ef12de0 100644 --- a/frontend/test/widget_test.dart +++ b/frontend/test/widget_test.dart @@ -13,7 +13,7 @@ import 'package:hightube/main.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + await tester.pumpWidget(HightubeApp()); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget); diff --git a/frontend/windows/runner/resources/app_icon.ico b/frontend/windows/runner/resources/app_icon.ico index c04e20caf6370ebb9253ad831cc31de4a9c965f6..c9180fefc019276c1227957ef8cc8554d2041e38 100644 GIT binary patch literal 1517 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