$ git clone http://tcclient.ion.nu/tc_client.git
commit 55feb9deed08bf6dd7a18b0e57246ea8ed42247f
Author: Alicia <...>
Date:   Tue Apr 7 06:49:01 2015 +0200

    Version 0.29

diff --git a/ChangeLog b/ChangeLog
index 99ac586..e64b4f4 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,3 +1,18 @@
+0.29:
+Added testbuilds.sh to automatically try building the project with different configurations to verify that the latest changes did not break it in some cases.
+Check for endianness macros in the configure script and if they are missing/broken, test endianness and add our own macros.
+If getkey() returns null, print a descriptive message and exit (instead of segfaulting)
+Added optional code to record incoming RTMP packets, add -DRTMP_DEBUG=1 to CFLAGS to enable.
+Added compatibility hacks for apple systems (building onto android ones)
+Handle /help without sending it as a message.
+camviewer: create a new pipe for showing our own cam instead of mixing it in with tc_client's output.
+camviewer: remove associated cameras when a user leaves (in case there was no explicit message about ending the stream)
+camviewer: support older versions of libav which had parts of libavutil in libavcore instead.
+Forked camviewer into tc_client-gtk to keep camviewer as a simpler demo while tc_client-gtk develops into a more complete client interface.
+tc_client-gtk: added a menubar and moved the broadcasting option to there instead of as a button.
+tc_client-gtk: scale cams to fit/fill into the window.
+tc_client-gtk: added a text view for chat, an input field and a user list, switched to a glade file instead of building the GUI manually from code.
+modbot: added a !syncvid command to synchronize video position (or to see a video that was started while having youtube videos disabled in the flash client)
 0.28:
 Delete streams when they stop, to allow reusing stream IDs.
 Added a /camdown command.
diff --git a/Makefile b/Makefile
index 72a37d8..19a173a 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-VERSION=0.28
+VERSION=0.29
 CFLAGS=-g3 -Wall $(shell curl-config --cflags)
 LIBS=-g3 $(shell curl-config --libs)
 ifneq ($(wildcard config.mk),)
@@ -9,12 +9,13 @@ IRCHACK_OBJ=utilities/irchack/irchack.o utilities/compat.o
 MODBOT_OBJ=utilities/modbot/modbot.o utilities/list.o utilities/modbot/queue.o
 CAMVIEWER_OBJ=utilities/camviewer/camviewer.o
 CURSEDCHAT_OBJ=utilities/cursedchat/cursedchat.o utilities/cursedchat/buffer.o utilities/compat.o utilities/list.o
+TC_CLIENT_GTK_OBJ=utilities/gtk/camviewer.o utilities/gtk/userlist.o utilities/gtk/media.o utilities/gtk/compat.o
 UTILS=irchack modbot
 ifdef GTK_LIBS
 ifdef AVCODEC_LIBS
 ifdef AVUTIL_LIBS
 ifdef SWSCALE_LIBS
-  UTILS+=camviewer
+  UTILS+=camviewer tc_client-gtk
   CFLAGS+=$(GTK_CFLAGS) $(AVCODEC_CFLAGS) $(AVUTIL_CFLAGS) $(SWSCALE_CFLAGS)
   ifdef AO_LIBS
     ifdef AVRESAMPLE_LIBS
@@ -55,8 +56,11 @@ camviewer: $(CAMVIEWER_OBJ)
 cursedchat: $(CURSEDCHAT_OBJ)
  $(CC) $(LDFLAGS) $^ $(LIBS) $(READLINE_LIBS) $(CURSES_LIBS) -o $@
 
+tc_client-gtk: $(TC_CLIENT_GTK_OBJ)
+ $(CC) $(LDFLAGS) $^ $(LIBS) $(GTK_LIBS) $(AVCODEC_LIBS) $(AVUTIL_LIBS) $(SWSCALE_LIBS) $(AVRESAMPLE_LIBS) $(SWRESAMPLE_LIBS) $(AO_LIBS) $(LIBV4L2_LIBS) -o $@
+
 clean:
- rm -f $(OBJ) $(IRCHACK_OBJ) $(MODBOT_OBJ) $(CAMVIEWER_OBJ) $(CURSEDCHAT_OBJ) tc_client irchack modbot camviewer cursedchat
+ rm -f $(OBJ) $(IRCHACK_OBJ) $(MODBOT_OBJ) $(CAMVIEWER_OBJ) $(CURSEDCHAT_OBJ) $(TC_CLIENT_GTK_OBJ) tc_client irchack modbot camviewer cursedchat tc_client-gtk
 
 tarball:
- tar -cJf tc_client-$(VERSION).tar.xz --transform='s|^|tc_client-$(VERSION)/|' Makefile client.c amfparser.c rtmp.c numlist.c amfwriter.c idlist.c colors.c endian.c media.c amfparser.h rtmp.h numlist.h amfwriter.h idlist.h colors.h endian.h media.h LICENSE README ChangeLog crossbuild.sh utilities/irchack/irchack.c utilities/modbot/modbot.c utilities/modbot/queue.c utilities/modbot/queue.h utilities/modbot/commands.html utilities/camviewer/camviewer.c utilities/cursedchat/cursedchat.c utilities/cursedchat/buffer.c utilities/cursedchat/buffer.h utilities/compat.c utilities/compat.h utilities/list.c utilities/list.h configure
+ tar -cJf tc_client-$(VERSION).tar.xz --transform='s|^|tc_client-$(VERSION)/|' Makefile client.c amfparser.c rtmp.c numlist.c amfwriter.c idlist.c colors.c endian.c media.c amfparser.h rtmp.h numlist.h amfwriter.h idlist.h colors.h endian.h media.h LICENSE README ChangeLog crossbuild.sh testbuilds.sh utilities/irchack/irchack.c utilities/modbot/modbot.c utilities/modbot/queue.c utilities/modbot/queue.h utilities/modbot/commands.html utilities/camviewer/camviewer.c utilities/cursedchat/cursedchat.c utilities/cursedchat/buffer.c utilities/cursedchat/buffer.h utilities/gtk/camviewer.c utilities/gtk/userlist.c utilities/gtk/media.c utilities/gtk/compat.c utilities/gtk/userlist.h utilities/gtk/media.h utilities/gtk/compat.h utilities/compat.c utilities/compat.h utilities/list.c utilities/list.h gtkgui.glade configure
diff --git a/client.c b/client.c
index b259d22..1f55248 100644
--- a/client.c
+++ b/client.c
@@ -229,8 +229,14 @@ void usage(const char* me)
          "-u, --user <user>    Username of tinychat account to use.\n"
          "-p, --pass <pass>    Password of tinychat account to use.\n"
          "-c, --color <value>  Color to use in chat.\n"
+#ifdef RTMP_DEBUG
+         "    --rtmplog <file> Write RTMP input to file.\n"
+#endif
          ,me);
 }
+#ifdef RTMP_DEBUG
+extern int rtmplog;
+#endif
 
 int main(int argc, char** argv)
 {
@@ -262,6 +268,9 @@ int main(int argc, char** argv)
       ++i;
       currentcolor=atoi(argv[i]);
     }
+#ifdef RTMP_DEBUG
+    else if(!strcmp(argv[i], "--rtmplog")){++i; rtmplog=open(argv[i], O_WRONLY|O_CREAT|O_TRUNC, 0600); if(rtmplog<0){perror("rtmplog: open");}}
+#endif
     else if(!channel){channel=argv[i];}
     else if(!nickname){nickname=strdup(argv[i]);}
     else if(!password){password=argv[i];}
@@ -437,6 +446,7 @@ int main(int argc, char** argv)
                  "/camdown        = close the broadcasting stream\n"
                  "/video <length> = send a <length> bytes long encoded frame, send the frame data after this line\n");
           fflush(stdout);
+          continue;
         }
         else if(!strncmp(buf, "/color", 6) && (!buf[6]||buf[6]==' '))
         {
@@ -642,6 +652,7 @@ int main(int argc, char** argv)
       char* id=amfin->items[amfin->itemcount-1].string.string;
       printf("Connection ID: %s\n", id);
       char* key=getkey(id, channel);
+      if(!key){printf("Failed to get channel key\n"); return 1;}
 
       amfinit(&amf, 3);
       amfstring(&amf, "cauth");
diff --git a/configure b/configure
index 166d010..78aaae3 100755
--- a/configure
+++ b/configure
@@ -151,4 +151,30 @@ else
   echo no
 fi
 
+# TODO: handle crosscompiling better
+printf 'Checking if endianness macros work... '
+echo '#include <stdio.h>' > endiantest.c
+echo 'int main(){' >> endiantest.c
+echo '#if defined(__ORDER_LITTLE_ENDIAN__) && defined(__ORDER_BIG_ENDIAN__) && defined(__BYTE_ORDER__)' >> endiantest.c
+echo '  if(__ORDER_LITTLE_ENDIAN__==__ORDER_BIG_ENDIAN__){return 1;}' >> endiantest.c
+echo '  if(__BYTE_ORDER__!=__ORDER_LITTLE_ENDIAN__ && __BYTE_ORDER__!=__ORDER_BIG_ENDIAN__){return 1;}' >> endiantest.c
+echo '  return 0;' >> endiantest.c
+echo '#else' >> endiantest.c
+echo '  return 1;' >> endiantest.c
+echo '#endif' >> endiantest.c
+echo '}' >> endiantest.c
+"$CC" endiantest.c -o endiantest > /dev/null 2> /dev/null
+if ./endiantest 2> /dev/null; then
+  echo yes
+else
+  echo no
+  printf 'Checking endianness and writing our own macros... '
+  echo '#include <stdio.h>' > endiantest.c
+  echo 'int main(){int num=1;char* x=(char*)&num;if(x[0]){printf("CFLAGS+=-D__ORDER_LITTLE_ENDIAN__=1 -D__ORDER_BIG_ENDIAN__=2 -D__BYTE_ORDER__=1\n");}else{printf("CFLAGS+=-D__ORDER_LITTLE_ENDIAN__=1 -D__ORDER_BIG_ENDIAN__=2 -D__BYTE_ORDER__=2\n");}return 0;}' >> endiantest.c
+  "$CC" endiantest.c -o endiantest > /dev/null 2> /dev/null
+  ./endiantest >> config.mk
+  echo done
+fi
+rm -f endiantest.c endiantest
+
 echo Done
diff --git a/gtkgui.glade b/gtkgui.glade
index 8a12566..9661ca8 100644
--- a/gtkgui.glade
+++ b/gtkgui.glade
@@ -2,48 +2,11 @@
 <!-- Generated with glade 3.18.3 -->
 <interface>
   <requires lib="gtk+" version="3.0"/>
-  <object class="GtkWindow" id="channelpasswordwindow">
-    <property name="can_focus">False</property>
-    <property name="title" translatable="yes">Channel password?</property>
-    <signal name="destroy" handler="gtk_main_quit" swapped="no"/>
-    <child>
-      <object class="GtkBox" id="box4">
-        <property name="visible">True</property>
-        <property name="can_focus">False</property>
-        <child>
-          <object class="GtkEntry" id="channelpassword">
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="visibility">False</property>
-            <property name="input_purpose">password</property>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">0</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkButton" id="channelpasswordbutton">
-            <property name="label" translatable="yes">Enter</property>
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="receives_default">True</property>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">1</property>
-          </packing>
-        </child>
-      </object>
-    </child>
-  </object>
   <object class="GtkWindow" id="main">
     <property name="can_focus">False</property>
     <property name="title" translatable="yes">tc_client</property>
-    <property name="default_width">300</property>
-    <property name="default_height">400</property>
+    <property name="default_width">200</property>
+    <property name="default_height">300</property>
     <signal name="destroy" handler="gtk_main_quit" swapped="no"/>
     <child>
       <object class="GtkBox" id="vbox1">
@@ -75,28 +38,6 @@
                 </child>
               </object>
             </child>
-            <child>
-              <object class="GtkMenuItem" id="menuitem1">
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="label" translatable="yes">Options</property>
-                <property name="use_underline">True</property>
-                <child type="submenu">
-                  <object class="GtkMenu" id="menu1">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <child>
-                      <object class="GtkMenuItem" id="menuitem_options_settings">
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="label" translatable="yes">Settings</property>
-                        <property name="use_underline">True</property>
-                      </object>
-                    </child>
-                  </object>
-                </child>
-              </object>
-            </child>
           </object>
           <packing>
             <property name="expand">False</property>
@@ -113,6 +54,8 @@
               <object class="GtkScrolledWindow" id="camerascroll">
                 <property name="visible">True</property>
                 <property name="can_focus">True</property>
+                <property name="hscrollbar_policy">never</property>
+                <property name="vscrollbar_policy">never</property>
                 <child>
                   <object class="GtkViewport" id="viewport1">
                     <property name="visible">True</property>
@@ -199,7 +142,6 @@
                   <object class="GtkEntry" id="inputfield">
                     <property name="visible">True</property>
                     <property name="can_focus">True</property>
-                    <property name="has_focus">True</property>
                   </object>
                   <packing>
                     <property name="expand">False</property>
@@ -223,526 +165,4 @@
       </object>
     </child>
   </object>
-  <object class="GtkWindow" id="settings">
-    <property name="can_focus">False</property>
-    <property name="title" translatable="yes">tc_client settings</property>
-    <property name="default_width">350</property>
-    <property name="default_height">200</property>
-    <signal name="delete-event" handler="gtk_widget_hide_on_delete" swapped="no"/>
-    <child>
-      <object class="GtkBox" id="box1">
-        <property name="visible">True</property>
-        <property name="can_focus">False</property>
-        <property name="orientation">vertical</property>
-        <child>
-          <object class="GtkNotebook" id="pager">
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="tab_pos">left</property>
-            <property name="scrollable">True</property>
-            <child>
-              <object class="GtkScrolledWindow" id="scrolledwindow1">
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="shadow_type">in</property>
-                <child>
-                  <object class="GtkViewport" id="viewport2">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <child>
-                      <object class="GtkBox" id="box2">
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="orientation">vertical</property>
-                        <child>
-                          <object class="GtkCheckButton" id="enable_logging">
-                            <property name="label" translatable="yes">Enable logging</property>
-                            <property name="visible">True</property>
-                            <property name="can_focus">True</property>
-                            <property name="receives_default">False</property>
-                            <property name="halign">start</property>
-                            <property name="xalign">0</property>
-                            <property name="draw_indicator">True</property>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">True</property>
-                            <property name="position">0</property>
-                          </packing>
-                        </child>
-                        <child>
-                          <object class="GtkLabel" id="label3">
-                            <property name="visible">True</property>
-                            <property name="can_focus">False</property>
-                            <property name="halign">start</property>
-                            <property name="label" translatable="yes">Path for channel: (%h=home directory, %c=channelname)</property>
-                            <property name="wrap">True</property>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">True</property>
-                            <property name="position">1</property>
-                          </packing>
-                        </child>
-                        <child>
-                          <object class="GtkEntry" id="logpath_channel">
-                            <property name="visible">True</property>
-                            <property name="sensitive">False</property>
-                            <property name="can_focus">True</property>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">True</property>
-                            <property name="position">2</property>
-                          </packing>
-                        </child>
-                        <child>
-                          <object class="GtkLabel" id="label4">
-                            <property name="visible">True</property>
-                            <property name="can_focus">False</property>
-                            <property name="halign">start</property>
-                            <property name="label" translatable="yes">Path for PMs: (%h=home directory, %c=channelname, %n=nickname)</property>
-                            <property name="wrap">True</property>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">True</property>
-                            <property name="position">3</property>
-                          </packing>
-                        </child>
-                        <child>
-                          <object class="GtkEntry" id="logpath_pm">
-                            <property name="visible">True</property>
-                            <property name="sensitive">False</property>
-                            <property name="can_focus">True</property>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">True</property>
-                            <property name="position">4</property>
-                          </packing>
-                        </child>
-                      </object>
-                    </child>
-                  </object>
-                </child>
-              </object>
-            </child>
-            <child type="tab">
-              <object class="GtkLabel" id="label1">
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="label" translatable="yes">Logging</property>
-              </object>
-              <packing>
-                <property name="tab_fill">False</property>
-              </packing>
-            </child>
-            <child>
-              <object class="GtkScrolledWindow" id="scrolledwindow2">
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="shadow_type">in</property>
-                <child>
-                  <object class="GtkViewport" id="viewport3">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <child>
-                      <object class="GtkBox" id="box3">
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="orientation">vertical</property>
-                        <child>
-                          <object class="GtkRadioButton" id="soundradio_none">
-                            <property name="visible">True</property>
-                            <property name="can_focus">True</property>
-                            <property name="receives_default">False</property>
-                            <property name="halign">start</property>
-                            <property name="xalign">0</property>
-                            <property name="active">True</property>
-                            <property name="draw_indicator">True</property>
-                            <child>
-                              <object class="GtkLabel" id="label14">
-                                <property name="visible">True</property>
-                                <property name="can_focus">False</property>
-                                <property name="label" translatable="yes">Do not play notification sounds</property>
-                                <property name="wrap">True</property>
-                              </object>
-                            </child>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">True</property>
-                            <property name="position">0</property>
-                          </packing>
-                        </child>
-                        <child>
-                          <object class="GtkRadioButton" id="soundradio_cmd">
-                            <property name="visible">True</property>
-                            <property name="can_focus">True</property>
-                            <property name="receives_default">False</property>
-                            <property name="halign">start</property>
-                            <property name="xalign">0</property>
-                            <property name="draw_indicator">True</property>
-                            <property name="group">soundradio_none</property>
-                            <child>
-                              <object class="GtkLabel" id="label13">
-                                <property name="visible">True</property>
-                                <property name="can_focus">False</property>
-                                <property name="label" translatable="yes">Run a command to play sounds:</property>
-                                <property name="wrap">True</property>
-                              </object>
-                            </child>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">True</property>
-                            <property name="position">1</property>
-                          </packing>
-                        </child>
-                        <child>
-                          <object class="GtkEntry" id="soundcmd">
-                            <property name="visible">True</property>
-                            <property name="sensitive">False</property>
-                            <property name="can_focus">True</property>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">True</property>
-                            <property name="position">2</property>
-                          </packing>
-                        </child>
-                      </object>
-                    </child>
-                  </object>
-                </child>
-              </object>
-              <packing>
-                <property name="position">1</property>
-              </packing>
-            </child>
-            <child type="tab">
-              <object class="GtkLabel" id="label2">
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="label" translatable="yes">Sound</property>
-              </object>
-              <packing>
-                <property name="position">1</property>
-                <property name="tab_fill">False</property>
-              </packing>
-            </child>
-            <child>
-              <object class="GtkScrolledWindow" id="scrolledwindow3">
-                <property name="visible">True</property>
-                <property name="can_focus">True</property>
-                <property name="shadow_type">in</property>
-                <child>
-                  <object class="GtkViewport" id="viewport4">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <child>
-                      <object class="GtkBox" id="box5">
-                        <property name="visible">True</property>
-                        <property name="can_focus">False</property>
-                        <property name="orientation">vertical</property>
-                        <child>
-                          <object class="GtkRadioButton" id="youtuberadio_none">
-                            <property name="visible">True</property>
-                            <property name="can_focus">True</property>
-                            <property name="receives_default">False</property>
-                            <property name="halign">start</property>
-                            <property name="xalign">0</property>
-                            <property name="active">True</property>
-                            <property name="draw_indicator">True</property>
-                            <child>
-                              <object class="GtkLabel" id="label12">
-                                <property name="visible">True</property>
-                                <property name="can_focus">False</property>
-                                <property name="halign">start</property>
-                                <property name="label" translatable="yes">Do not play youtube videos</property>
-                                <property name="wrap">True</property>
-                              </object>
-                            </child>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">True</property>
-                            <property name="position">0</property>
-                          </packing>
-                        </child>
-                        <child>
-                          <object class="GtkRadioButton" id="youtuberadio_cmd">
-                            <property name="visible">True</property>
-                            <property name="can_focus">True</property>
-                            <property name="receives_default">False</property>
-                            <property name="halign">start</property>
-                            <property name="xalign">0</property>
-                            <property name="active">True</property>
-                            <property name="draw_indicator">True</property>
-                            <property name="group">youtuberadio_none</property>
-                            <child>
-                              <object class="GtkLabel" id="label11">
-                                <property name="visible">True</property>
-                                <property name="can_focus">False</property>
-                                <property name="halign">start</property>
-                                <property name="label" translatable="yes">Run a command to play youtube videos (%i=video ID, %t=offset in milliseconds):</property>
-                                <property name="wrap">True</property>
-                              </object>
-                            </child>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">True</property>
-                            <property name="position">1</property>
-                          </packing>
-                        </child>
-                        <child>
-                          <object class="GtkEntry" id="youtubecmd">
-                            <property name="visible">True</property>
-                            <property name="can_focus">True</property>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">True</property>
-                            <property name="position">2</property>
-                          </packing>
-                        </child>
-                        <child>
-                          <object class="GtkLabel" id="label15">
-                            <property name="visible">True</property>
-                            <property name="can_focus">False</property>
-                            <property name="label" translatable="yes">Example: youtube-dl -o - -- '%i' | mplayer -ss "`expr '%t' / 1000`" -</property>
-                            <property name="wrap">True</property>
-                            <property name="selectable">True</property>
-                          </object>
-                          <packing>
-                            <property name="expand">False</property>
-                            <property name="fill">True</property>
-                            <property name="position">3</property>
-                          </packing>
-                        </child>
-                      </object>
-                    </child>
-                  </object>
-                </child>
-              </object>
-              <packing>
-                <property name="position">2</property>
-              </packing>
-            </child>
-            <child type="tab">
-              <object class="GtkLabel" id="label10">
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="label" translatable="yes">Youtube</property>
-              </object>
-              <packing>
-                <property name="position">2</property>
-                <property name="tab_fill">False</property>
-              </packing>
-            </child>
-          </object>
-          <packing>
-            <property name="expand">True</property>
-            <property name="fill">True</property>
-            <property name="position">0</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkButton" id="save_settings">
-            <property name="label" translatable="yes">OK</property>
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="receives_default">True</property>
-            <property name="halign">end</property>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">False</property>
-            <property name="position">1</property>
-          </packing>
-        </child>
-      </object>
-    </child>
-  </object>
-  <object class="GtkWindow" id="startwindow">
-    <property name="can_focus">False</property>
-    <property name="title" translatable="yes">tc_client</property>
-    <property name="resizable">False</property>
-    <signal name="destroy" handler="gtk_main_quit" swapped="no"/>
-    <child>
-      <object class="GtkBox" id="box6">
-        <property name="visible">True</property>
-        <property name="can_focus">False</property>
-        <property name="orientation">vertical</property>
-        <child>
-          <object class="GtkExpander" id="expander1">
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <child>
-              <object class="GtkBox" id="box7">
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="orientation">vertical</property>
-                <child>
-                  <object class="GtkLabel" id="label8">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <property name="halign">start</property>
-                    <property name="label" translatable="yes">Username:</property>
-                  </object>
-                  <packing>
-                    <property name="expand">False</property>
-                    <property name="fill">True</property>
-                    <property name="position">0</property>
-                  </packing>
-                </child>
-                <child>
-                  <object class="GtkEntry" id="acc_username">
-                    <property name="visible">True</property>
-                    <property name="can_focus">True</property>
-                  </object>
-                  <packing>
-                    <property name="expand">False</property>
-                    <property name="fill">True</property>
-                    <property name="position">1</property>
-                  </packing>
-                </child>
-                <child>
-                  <object class="GtkLabel" id="label9">
-                    <property name="visible">True</property>
-                    <property name="can_focus">False</property>
-                    <property name="halign">start</property>
-                    <property name="label" translatable="yes">Password:</property>
-                  </object>
-                  <packing>
-                    <property name="expand">False</property>
-                    <property name="fill">True</property>
-                    <property name="position">2</property>
-                  </packing>
-                </child>
-                <child>
-                  <object class="GtkEntry" id="acc_password">
-                    <property name="visible">True</property>
-                    <property name="can_focus">True</property>
-                    <property name="visibility">False</property>
-                    <property name="input_purpose">password</property>
-                  </object>
-                  <packing>
-                    <property name="expand">False</property>
-                    <property name="fill">True</property>
-                    <property name="position">3</property>
-                  </packing>
-                </child>
-                <child>
-                  <object class="GtkCheckButton" id="start_rememberacc">
-                    <property name="label" translatable="yes">Remember account</property>
-                    <property name="visible">True</property>
-                    <property name="can_focus">True</property>
-                    <property name="receives_default">False</property>
-                    <property name="xalign">0</property>
-                    <property name="draw_indicator">True</property>
-                  </object>
-                  <packing>
-                    <property name="expand">False</property>
-                    <property name="fill">True</property>
-                    <property name="position">4</property>
-                  </packing>
-                </child>
-              </object>
-            </child>
-            <child type="label">
-              <object class="GtkLabel" id="label5">
-                <property name="visible">True</property>
-                <property name="can_focus">False</property>
-                <property name="label" translatable="yes">Account</property>
-              </object>
-            </child>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">0</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkLabel" id="label6">
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <property name="halign">start</property>
-            <property name="label" translatable="yes">Nickname:</property>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">1</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkEntry" id="start_nick">
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">2</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkLabel" id="label7">
-            <property name="visible">True</property>
-            <property name="can_focus">False</property>
-            <property name="halign">start</property>
-            <property name="label" translatable="yes">Channel:</property>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">3</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkEntry" id="start_channel">
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">4</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkCheckButton" id="start_rememberchan">
-            <property name="label" translatable="yes">Remember nickname and channel</property>
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="receives_default">False</property>
-            <property name="xalign">0</property>
-            <property name="draw_indicator">True</property>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">5</property>
-          </packing>
-        </child>
-        <child>
-          <object class="GtkButton" id="connectbutton">
-            <property name="label" translatable="yes">Connect</property>
-            <property name="visible">True</property>
-            <property name="can_focus">True</property>
-            <property name="receives_default">True</property>
-          </object>
-          <packing>
-            <property name="expand">False</property>
-            <property name="fill">True</property>
-            <property name="position">6</property>
-          </packing>
-        </child>
-      </object>
-    </child>
-  </object>
 </interface>
diff --git a/media.c b/media.c
index 7e2e166..b224911 100644
--- a/media.c
+++ b/media.c
@@ -52,6 +52,7 @@ void stream_start(const char* nick, int sock) // called upon privmsg "/opencam .
   amfnull(&amf);
   amfsend(&amf, sock);
   printf("Starting media stream for %s (%u)\n", nick, userid);
+  fflush(stdout);
 }
 
 void streamout_start(unsigned int id, int sock) // called upon privmsg "/camup"
diff --git a/numlist.c b/numlist.c
index e39aad6..47635f1 100644
--- a/numlist.c
+++ b/numlist.c
@@ -31,7 +31,7 @@ char* fromnumlist(char* in, size_t* outlen)
     ++len;
     x=&x[1];
   }
-#ifdef __ANDROID__
+#if defined(__ANDROID__) || defined(__APPLE__)
   *outlen=len;
   unsigned char* string=malloc(len+1);
 #else
@@ -46,7 +46,7 @@ char* fromnumlist(char* in, size_t* outlen)
   }
   string[len]=0;
 
-#ifdef __ANDROID__
+#if defined(__ANDROID__) || defined(__APPLE__)
   return string;
 #else
   iconv_t cd=iconv_open("", "utf-16");
@@ -66,7 +66,7 @@ char* fromnumlist(char* in, size_t* outlen)
 
 char* tonumlist(char* i_in, size_t len)
 {
-#ifdef __ANDROID__
+#if defined(__ANDROID__) || defined(__APPLE__)
   #define in i_in
 #else
   iconv_t cd=iconv_open("utf-16le", "");
diff --git a/rtmp.c b/rtmp.c
index 2956d12..b17b704 100644
--- a/rtmp.c
+++ b/rtmp.c
@@ -34,6 +34,9 @@ struct chunk
 struct chunk* chunks=0;
 unsigned int chunkcount=0;
 unsigned int chunksize_in=128;
+#ifdef RTMP_DEBUG
+int rtmplog=-1;
+#endif
 
 size_t fullread(int fd, void* buf, size_t len)
 {
@@ -42,6 +45,12 @@ size_t fullread(int fd, void* buf, size_t len)
   {
     size_t r=read(fd, buf, remaining);
     if(r<1){return 0;}
+#ifdef RTMP_DEBUG
+    if(rtmplog>-1)
+    {
+      write(rtmplog, buf, r);
+    }
+#endif
     remaining-=r;
     buf+=r;
   }
diff --git a/utilities/camviewer/camviewer.c b/utilities/camviewer/camviewer.c
index a11142b..174c60a 100644
--- a/utilities/camviewer/camviewer.c
+++ b/utilities/camviewer/camviewer.c
@@ -21,7 +21,11 @@
 #include <sys/prctl.h>
 #include <libavcodec/avcodec.h>
 #include <libswscale/swscale.h>
-#include <libavutil/imgutils.h>
+#if LIBAVUTIL_VERSION_MAJOR>50 || (LIBAVUTIL_VERSION_MAJOR==50 && LIBAVUTIL_VERSION_MINOR>37)
+  #include <libavutil/imgutils.h>
+#else
+  #include <libavcore/imgutils.h>
+#endif
 #ifdef HAVE_SOUND
 // TODO: use libavresample instead if available
   #if HAVE_SOUND==avresample
@@ -118,6 +122,28 @@ void camera_playsnd(struct viddata* data, struct camera* cam, short* samples, un
 }
 #endif
 
+void camera_remove(struct viddata* data, const char* nick)
+{
+  unsigned int i;
+  for(i=0; i<data->camcount; ++i)
+  {
+    if(!strcmp(data->cams[i].id, nick))
+    {
+      gtk_widget_destroy(data->cams[i].box);
+      av_frame_free(&data->cams[i].frame);
+      avcodec_free_context(&data->cams[i].vctx);
+#ifdef HAVE_SOUND
+      avcodec_free_context(&data->cams[i].actx);
+#endif
+      free(data->cams[i].id);
+      free(data->cams[i].nick);
+      --data->camcount;
+      memmove(&data->cams[i], &data->cams[i+1], (data->camcount-i)*sizeof(struct camera));
+      break;
+    }
+  }
+}
+
 char buf[1024];
 gboolean handledata(GIOChannel* channel, GIOCondition condition, gpointer datap)
 {
@@ -151,6 +177,13 @@ gboolean handledata(GIOChannel* channel, GIOCondition condition, gpointer datap)
     dprintf(tc_client_in[1], "/opencam %s\n", buf);
     return 1;
   }
+  // Make sure the cam goes away when a user leaves
+  else if(space && !strcmp(space, " left the channel"))
+  {
+    space[0]=0;
+    camera_remove(data, buf);
+    return 1;
+  }
   if(!strncmp(buf, "Starting media stream for ", 26))
   {
     char* nick=&buf[26];
@@ -204,23 +237,7 @@ gboolean handledata(GIOChannel* channel, GIOCondition condition, gpointer datap)
   }
   if(!strncmp(buf, "VideoEnd: ", 10))
   {
-    for(i=0; i<data->camcount; ++i)
-    {
-      if(!strcmp(data->cams[i].id, &buf[10]))
-      {
-        gtk_widget_destroy(data->cams[i].box);
-        av_frame_free(&data->cams[i].frame);
-        avcodec_free_context(&data->cams[i].vctx);
-#ifdef HAVE_SOUND
-        avcodec_free_context(&data->cams[i].actx);
-#endif
-        free(data->cams[i].id);
-        free(data->cams[i].nick);
-        --data->camcount;
-        memmove(&data->cams[i], &data->cams[i+1], (data->camcount-i)*sizeof(struct camera));
-        break;
-      }
-    }
+    camera_remove(data, &buf[10]);
     return 1;
   }
   if(!strncmp(buf, "Audio: ", 7))
@@ -350,12 +367,16 @@ void togglecam(GtkButton* button, struct viddata* data)
     dprintf(tc_client[1], "VideoEnd: out\n"); // Close our local display
     return;
   }
+  // Set up a second pipe to be handled by handledata() to avoid overlap with tc_client's output
+  int campipe[2];
+  pipe(campipe);
   dprintf(tc_client_in[1], "/camup\n");
   gtk_button_set_label(button, "Stop broadcasting");
 // printf("Camming up!\n");
   camproc=fork();
   if(!camproc)
   {
+    close(campipe[0]);
     prctl(PR_SET_PDEATHSIG, SIGHUP);
     unsigned int delay=500000;
     // Set up camera
@@ -413,15 +434,19 @@ packet.size=0;
       write(tc_client_in[1], &frameinfo, 1);
       write(tc_client_in[1], packet.data, packet.size);
       // Also send the packet to our main thread so we can see ourselves
-      dprintf(tc_client[1], "Video: out %i\n", packet.size+1);
-      write(tc_client[1], &frameinfo, 1);
-      write(tc_client[1], packet.data, packet.size);
+      dprintf(campipe[1], "Video: out %i\n", packet.size+1);
+      write(campipe[1], &frameinfo, 1);
+      write(campipe[1], packet.data, packet.size);
 
       av_free_packet(&packet);
     }
     sws_freeContext(swsctx);
     _exit(0);
   }
+  close(campipe[1]);
+  GIOChannel* channel=g_io_channel_unix_new(campipe[0]);
+  g_io_channel_set_encoding(channel, 0, 0);
+  g_io_add_watch(channel, G_IO_IN, handledata, data);
 }
 #endif
 
diff --git a/utilities/gtk/camviewer.c b/utilities/gtk/camviewer.c
new file mode 100644
index 0000000..c90ca37
--- /dev/null
+++ b/utilities/gtk/camviewer.c
@@ -0,0 +1,721 @@
+/*
+    tc_client-gtk, a graphical user interface for tc_client
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+#include <unistd.h>
+#include <fcntl.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/prctl.h>
+#include <ctype.h>
+#include <libavcodec/avcodec.h>
+#include <libswscale/swscale.h>
+#if LIBAVUTIL_VERSION_MAJOR>50 || (LIBAVUTIL_VERSION_MAJOR==50 && LIBAVUTIL_VERSION_MINOR>37)
+  #include <libavutil/imgutils.h>
+#else
+  #include <libavcore/imgutils.h>
+#endif
+#ifdef HAVE_SOUND
+  // Use libavresample if available, otherwise fall back on libswresample
+  #if HAVE_SOUND==avresample
+    #include <libavutil/opt.h>
+    #include <libavresample/avresample.h>
+  #else
+    #include <libswresample/swresample.h>
+  #endif
+  #include <ao/ao.h>
+#endif
+#include <gtk/gtk.h>
+#include <gdk/gdkkeysyms.h>
+#ifdef HAVE_V4L2
+  #include <libv4l2.h>
+  #include <linux/videodev2.h>
+#endif
+#include "userlist.h"
+#include "media.h"
+#include "compat.h"
+
+struct viddata
+{
+  GtkWidget* box;
+  AVCodec* vdecoder;
+  AVCodec* vencoder;
+  AVCodec* adecoder;
+  int scalewidth;
+  int scaleheight;
+#ifdef HAVE_SOUND
+  int audiopipe;
+  #if HAVE_SOUND==avresample
+    AVAudioResampleContext* resamplectx;
+  #else
+    SwrContext* swrctx;
+  #endif
+#endif
+  GtkTextBuffer* buffer; // TODO: struct buffer array, for PMs
+  GtkAdjustment* scroll;
+};
+
+int tc_client[2];
+int tc_client_in[2];
+
+char autoscroll_before(GtkAdjustment* scroll)
+{
+  // Figure out if we're at the bottom and should autoscroll with new content
+  int upper=gtk_adjustment_get_upper(scroll);
+  int size=gtk_adjustment_get_page_size(scroll);
+  int value=gtk_adjustment_get_value(scroll);
+  return (value+size==upper);
+}
+
+void autoscroll_after(GtkAdjustment* scroll)
+{
+  while(gtk_events_pending()){gtk_main_iteration();} // Make sure the textview's new size affects scroll's "upper" value first
+  int upper=gtk_adjustment_get_upper(scroll);
+  int size=gtk_adjustment_get_page_size(scroll);
+  gtk_adjustment_set_value(scroll, upper-size);
+}
+
+void updatescaling(struct viddata* data, unsigned int width, unsigned int height)
+{
+// TODO: Move updatescaling into media.c?
+  if(!camcount){return;}
+  if(!width){width=gtk_widget_get_allocated_width(data->box);}
+  if(!height){height=gtk_widget_get_allocated_height(data->box);}
+  data->scalewidth=width/camcount;
+  // 3/4 ratio
+  data->scaleheight=data->scalewidth*3/4;
+  unsigned int i;
+  unsigned int labelsize=0;
+  for(i=0; i<camcount; ++i)
+  {
+    if(gtk_widget_get_allocated_height(cams[i].label)>labelsize)
+      labelsize=gtk_widget_get_allocated_height(cams[i].label);
+  }
+  // Fit by height
+  if(height<data->scaleheight+labelsize)
+  {
+    data->scaleheight=height-labelsize;
+    data->scalewidth=data->scaleheight*4/3;
+  }
+  if(data->scalewidth<8){data->scalewidth=8;}
+  if(data->scaleheight<1){data->scaleheight=1;}
+  // TODO: wrapping and stuff
+  // Rescale current images to fit
+  for(i=0; i<camcount; ++i)
+  {
+    GdkPixbuf* pixbuf=gtk_image_get_pixbuf(GTK_IMAGE(cams[i].cam));
+    if(!pixbuf){continue;}
+    pixbuf=gdk_pixbuf_scale_simple(pixbuf, data->scalewidth, data->scaleheight, GDK_INTERP_BILINEAR);
+    gtk_image_set_from_pixbuf(GTK_IMAGE(cams[i].cam), pixbuf);
+// TODO: figure out/fix the "static" noise that seems to happen here
+  }
+}
+
+void printchat(struct viddata* data, const char* text)
+{
+  char bottom=autoscroll_before(data->scroll);
+  // Insert new content
+  GtkTextIter end;
+  gtk_text_buffer_get_end_iter(data->buffer, &end);
+  gtk_text_buffer_insert(data->buffer, &end, "\n", -1);
+  gtk_text_buffer_insert(data->buffer, &end, text, -1);
+  if(bottom){autoscroll_after(data->scroll);}
+}
+
+char buf[1024];
+gboolean handledata(GIOChannel* channel, GIOCondition condition, gpointer datap)
+{
+  int fd=g_io_channel_unix_get_fd(channel);
+  struct viddata* data=datap;
+  unsigned int i;
+  for(i=0; i<1023; ++i)
+  {
+    if(read(fd, &buf[i], 1)<1){printf("No more data\n"); gtk_main_quit(); return 0;}
+    if(buf[i]=='\r'||buf[i]=='\n'){break;}
+  }
+  buf[i]=0;
+  if(!strncmp(buf, "Currently online: ", 18))
+  {
+    printchat(data, buf);
+    char* next=&buf[16];
+    while(next)
+    {
+      char* nick=&next[2];
+      next=strstr(nick, ", ");
+      if(next){next[0]=0;}
+      adduser(nick);
+    }
+    return 1;
+  }
+  // Start streams once we're properly connected
+  if(!strncmp(buf, "Currently on cam: ", 18))
+  {
+    printchat(data, buf);
+    char* next=&buf[16];
+    while(next)
+    {
+      char* user=&next[2];
+      next=strstr(user, ", ");
+      if(!user[0]){continue;}
+      if(next){next[0]=0;}
+      dprintf(tc_client_in[1], "/opencam %s\n", user);
+    }
+    return 1;
+  }
+  char* space=strchr(buf, ' ');
+  // Timestamped events
+  if(buf[0]=='['&&isdigit(buf[1])&&isdigit(buf[2])&&buf[3]==':'&&isdigit(buf[4])&&isdigit(buf[5])&&buf[6]==']'&&buf[7]==' ')
+  {
+    char* nick=&buf[8];
+    space=strchr(nick, ' ');
+    if(!space){return 1;}
+//    if(space[-1]==':') // TODO: Handle messages specifically, e.g. handle /msg
+    // Remove escape codes and pick up the text color while we're at it
+    char* color=0;
+    char* esc;
+    char* escend;
+    while((esc=strchr(buf, '\x1b'))&&(escend=strchr(esc, 'm')))
+    {
+      escend[0]=0;
+      if(!color && strcmp(&esc[1], "[0")){color=strdup(&esc[1]);}
+      memmove(esc, &escend[1], strlen(&escend[1])+1);
+    }
+    char bottom=autoscroll_before(data->scroll);
+    // Insert new content
+    GtkTextIter end;
+    gtk_text_buffer_get_end_iter(data->buffer, &end);
+    gtk_text_buffer_insert(data->buffer, &end, "\n", -1);
+    int startnum=gtk_text_iter_get_offset(&end);
+    gtk_text_buffer_insert(data->buffer, &end, buf, -1);
+    if(color) // Set color if there was one
+    {
+      GtkTextIter start;
+      gtk_text_buffer_get_iter_at_offset(data->buffer, &start, startnum+8); // 8 to skip timestamps
+      gtk_text_buffer_apply_tag_by_name(data->buffer, color, &start, &end);
+    }
+    if(bottom){autoscroll_after(data->scroll);}
+    if(space[-1]!=':') // Not a message
+    {
+      if(!strcmp(space, " entered the channel"))
+      {
+        space[0]=0;
+        adduser(nick);
+      }
+      else if(!strcmp(space, " left the channel"))
+      {
+        space[0]=0;
+        removeuser(nick);
+        camera_removebynick(nick);
+      }
+      else if(!strncmp(space, " changed nickname to ", 21))
+      {
+        space[0]=0;
+        renameuser(nick, &space[21]);
+        struct camera* cam=camera_findbynick(nick);
+        if(cam)
+        {
+          free(cam->nick);
+          cam->nick=strdup(&space[21]);
+          gtk_label_set_text(GTK_LABEL(cam->label), cam->nick);
+        }
+      }
+    }
+    return 1;
+  }
+  if(space && !strcmp(space, " is a moderator."))
+  {
+    space[0]=0;
+    struct user* user=finduser(buf);
+    if(user)
+    {
+      user->ismod=1;
+      renameuser(buf, buf); // Update the userlist label
+    }
+    return 1;
+  }
+  if(space && !strcmp(space, " is no longer a moderator."))
+  {
+    space[0]=0;
+    struct user* user=finduser(buf);
+    if(user){user->ismod=0;}
+    return 1;
+  }
+  // Start a stream when someone cams up
+  if(space && !strcmp(space, " cammed up"))
+  {
+    space[0]=0;
+    dprintf(tc_client_in[1], "/opencam %s\n", buf);
+    return 1;
+  }
+  if(!strncmp(buf, "Starting media stream for ", 26))
+  {
+    char* nick=&buf[26];
+    char* id=strstr(nick, " (");
+    if(!id){return 1;}
+    id[0]=0;
+    id=&id[2];
+    char* idend=strchr(id, ')');
+    if(!idend){return 1;}
+    idend[0]=0;
+    camera_removebynick(nick); // Remove any duplicates
+    struct camera* cam=camera_new();
+    cam->frame=av_frame_alloc();
+    cam->dstframe=av_frame_alloc();
+    cam->nick=strdup(nick);
+    cam->id=strdup(id);
+    cam->vctx=avcodec_alloc_context3(data->vdecoder);
+    avcodec_open2(cam->vctx, data->vdecoder, 0);
+#ifdef HAVE_SOUND
+    cam->actx=avcodec_alloc_context3(data->adecoder);
+    avcodec_open2(cam->actx, data->adecoder, 0);
+    cam->samples=0;
+#endif
+    cam->cam=gtk_image_new();
+    cam->box=gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
+    gtk_box_set_homogeneous(GTK_BOX(cam->box), 0);
+    gtk_box_pack_start(GTK_BOX(cam->box), cam->cam, 0, 0, 0);
+    cam->label=gtk_label_new(cam->nick);
+    gtk_box_pack_start(GTK_BOX(cam->box), cam->label, 0, 0, 0);
+    gtk_box_pack_start(GTK_BOX(data->box), cam->box, 0, 0, 0);
+    gtk_widget_show_all(cam->box);
+    updatescaling(data, 0, 0);
+    while(gtk_events_pending()){gtk_main_iteration();} // Make sure the label gets its size before we calculate scaling
+    updatescaling(data, 0, 0);
+    return 1;
+  }
+  if(!strcmp(buf, "Starting outgoing media stream"))
+  {
+    struct camera* cam=camera_new();
+    cam->frame=av_frame_alloc();
+    cam->dstframe=av_frame_alloc();
+    cam->nick=strdup("You");
+    cam->id=strdup("out");
+    cam->vctx=avcodec_alloc_context3(data->vdecoder);
+    avcodec_open2(cam->vctx, data->vdecoder, 0);
+    cam->actx=0;
+    cam->cam=gtk_image_new();
+    cam->box=gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
+    gtk_box_pack_start(GTK_BOX(cam->box), cam->cam, 0, 0, 0);
+    cam->label=gtk_label_new(cam->nick);
+    gtk_box_pack_start(GTK_BOX(cam->box), cam->label, 0, 0, 0);
+    gtk_box_pack_start(GTK_BOX(data->box), cam->box, 0, 0, 0);
+    gtk_widget_show_all(cam->box);
+    updatescaling(data, 0, 0);
+    while(gtk_events_pending()){gtk_main_iteration();} // Make sure the label gets its size before we calculate scaling
+    updatescaling(data, 0, 0);
+    return 1;
+  }
+  if(!strncmp(buf, "VideoEnd: ", 10))
+  {
+    camera_remove(&buf[10]);
+    updatescaling(data, 0, 0);
+    return 1;
+  }
+  if(!strncmp(buf, "Audio: ", 7))
+  {
+    char* sizestr=strchr(&buf[7], ' ');
+    if(!sizestr){return 1;}
+    sizestr[0]=0;
+    unsigned int size=strtoul(&sizestr[1], 0, 0);
+    if(!size){return 1;}
+    unsigned char frameinfo;
+    read(fd, &frameinfo, 1);
+    --size; // For the byte we read above
+    AVPacket pkt;
+    av_init_packet(&pkt);
+    unsigned char databuf[size];
+    pkt.data=databuf;
+    pkt.size=size;
+    unsigned int pos=0;
+    while(pos<size)
+    {
+      pos+=read(fd, pkt.data+pos, size-pos);
+    }
+#ifdef HAVE_SOUND
+    // Find the camera representation for the given ID (for decoder context)
+    struct camera* cam=camera_find(&buf[7]);
+    if(!cam){printf("No cam found with ID '%s'\n", &buf[7]); return 1;}
+    int gotframe;
+    avcodec_decode_audio4(cam->actx, cam->frame, &gotframe, &pkt);
+    if(!gotframe){return 1;}
+  #if HAVE_SOUND==avresample
+    int outlen=avresample_convert(data->resamplectx, cam->frame->data, cam->frame->linesize[0], cam->frame->nb_samples, cam->frame->data, cam->frame->linesize[0], cam->frame->nb_samples);
+  #else
+    int outlen=swr_convert(data->resamplectx, cam->frame->data, cam->frame->nb_samples, (const uint8_t**)cam->frame->data, cam->frame->nb_samples);
+  #endif
+    camera_playsnd(data->audiopipe, cam, (short*)cam->frame->data[0], outlen);
+#endif
+    return 1;
+  }
+  if(strncmp(buf, "Video: ", 7)){printf("Got '%s'\n", buf); fflush(stdout); return 1;} // Ignore anything else that isn't video
+  char* sizestr=strchr(&buf[7], ' ');
+  if(!sizestr){return 1;}
+  sizestr[0]=0;
+  // Find the camera representation for the given ID
+  struct camera* cam=camera_find(&buf[7]);
+  unsigned int size=strtoul(&sizestr[1], 0, 0);
+  if(!size){return 1;}
+  // Mostly ignore the first byte (contains frame type (e.g. keyframe etc.) in 4 bits and codec in the other 4)
+  --size;
+  AVPacket pkt;
+  av_init_packet(&pkt);
+  unsigned char databuf[size+4];
+  pkt.data=databuf;
+  unsigned char frameinfo;
+  read(fd, &frameinfo, 1);
+// printf("Frametype-frame: %x\n", ((unsigned int)frameinfo&0xf0)/16);
+// printf("Frametype-codec: %x\n", (unsigned int)frameinfo&0xf);
+  unsigned int pos=0;
+  while(pos<size)
+  {
+    pos+=read(fd, pkt.data+pos, size-pos);
+  }
+  if((frameinfo&0xf)!=2){return 1;} // Not FLV1, get data but discard it
+  if(!cam){printf("No cam found with ID '%s'\n", &buf[7]); return 1;}
+  pkt.size=size;
+  int gotframe;
+  avcodec_decode_video2(cam->vctx, cam->frame, &gotframe, &pkt);
+  if(!gotframe){return 1;}
+
+  // Scale and convert to RGB24 format
+  unsigned int bufsize=avpicture_get_size(PIX_FMT_RGB24, data->scalewidth, data->scaleheight);
+  unsigned char buf[bufsize];
+  cam->dstframe->data[0]=buf;
+  cam->dstframe->linesize[0]=data->scalewidth*3;
+  struct SwsContext* swsctx=sws_getContext(cam->frame->width, cam->frame->height, cam->frame->format, data->scalewidth, data->scaleheight, AV_PIX_FMT_RGB24, 0, 0, 0, 0);
+  sws_scale(swsctx, (const uint8_t*const*)cam->frame->data, cam->frame->linesize, 0, cam->frame->height, cam->dstframe->data, cam->dstframe->linesize);
+  sws_freeContext(swsctx);
+
+  GdkPixbuf* gdkframe=gdk_pixbuf_new_from_data(cam->dstframe->data[0], GDK_COLORSPACE_RGB, 0, 8, data->scalewidth, data->scaleheight, cam->dstframe->linesize[0], 0, 0);
+  gtk_image_set_from_pixbuf(GTK_IMAGE(cam->cam), gdkframe);
+  // Make sure it gets redrawn in time
+  gdk_window_process_updates(gtk_widget_get_window(cam->cam), 1);
+
+  return 1;
+}
+
+#ifdef HAVE_SOUND
+void audiothread(int fd)
+{
+  ao_initialize();
+  ao_sample_format samplefmt;
+  samplefmt.bits=16;
+  samplefmt.rate=22050;
+  samplefmt.channels=1;
+  samplefmt.byte_format=AO_FMT_NATIVE; // I'm guessing libavcodec decodes it to native
+  samplefmt.matrix=0;
+  ao_option clientname={.key="client_name", .value="tc_client/camviewer", .next=0};
+  ao_device* dev=ao_open_live(ao_default_driver_id(), &samplefmt, &clientname);
+  char buf[2048];
+  size_t len;
+  while((len=read(fd, buf, 2048))>0)
+  {
+    ao_play(dev, buf, len);
+  }
+  ao_close(dev);
+}
+#endif
+
+#ifdef HAVE_V4L2
+pid_t camproc=0;
+void togglecam(GtkCheckMenuItem* item, struct viddata* data)
+{
+  if(!gtk_check_menu_item_get_active(item))
+  {
+    kill(camproc, SIGINT);
+    camproc=0;
+    dprintf(tc_client_in[1], "/camdown\n");
+    dprintf(tc_client[1], "VideoEnd: out\n"); // Close our local display
+    return;
+  }
+  // Set up a second pipe to be handled by handledata() to avoid overlap with tc_client's output
+  int campipe[2];
+  pipe(campipe);
+  dprintf(tc_client_in[1], "/camup\n");
+// printf("Camming up!\n");
+  camproc=fork();
+  if(!camproc)
+  {
+    close(campipe[0]);
+    prctl(PR_SET_PDEATHSIG, SIGHUP);
+    unsigned int delay=500000;
+    // Set up camera
+    int fd=v4l2_open("/dev/video0", O_RDWR);
+    struct v4l2_format fmt;
+    fmt.type=V4L2_BUF_TYPE_VIDEO_CAPTURE;
+    fmt.fmt.pix.width=320;
+    fmt.fmt.pix.height=240;
+    fmt.fmt.pix.pixelformat=V4L2_PIX_FMT_RGB24;
+    fmt.fmt.pix.field=V4L2_FIELD_NONE;
+    fmt.fmt.pix.bytesperline=fmt.fmt.pix.width*3;
+    fmt.fmt.pix.sizeimage=fmt.fmt.pix.bytesperline*fmt.fmt.pix.height;
+    v4l2_ioctl(fd, VIDIOC_S_FMT, &fmt);
+    AVCodecContext* ctx=avcodec_alloc_context3(data->vencoder);
+    ctx->width=fmt.fmt.pix.width;
+    ctx->height=fmt.fmt.pix.height;
+    ctx->pix_fmt=PIX_FMT_YUV420P;
+    ctx->time_base.num=1;
+    ctx->time_base.den=10;
+    avcodec_open2(ctx, data->vencoder, 0);
+    AVFrame* frame=av_frame_alloc();
+    frame->format=PIX_FMT_RGB24;
+    frame->width=fmt.fmt.pix.width;
+    frame->height=fmt.fmt.pix.height;
+    av_image_alloc(frame->data, frame->linesize, ctx->width, ctx->height, frame->format, 1);
+    AVPacket packet;
+    packet.buf=0;
+    packet.data=0;
+    packet.size=0;
+    packet.dts=AV_NOPTS_VALUE;
+    packet.pts=AV_NOPTS_VALUE;
+
+    // Set up frame for conversion from the camera's format to a format the encoder can use
+    AVFrame* dstframe=av_frame_alloc();
+    dstframe->format=ctx->pix_fmt;
+    dstframe->width=ctx->width;
+    dstframe->height=ctx->height;
+    av_image_alloc(dstframe->data, dstframe->linesize, ctx->width, ctx->height, ctx->pix_fmt, 1);
+
+    struct SwsContext* swsctx=sws_getContext(frame->width, frame->height, PIX_FMT_RGB24, frame->width, frame->height, AV_PIX_FMT_YUV420P, 0, 0, 0, 0);
+
+    while(1)
+    {
+      usleep(delay);
+      if(delay>100000){delay-=50000;}
+      v4l2_read(fd, frame->data[0], fmt.fmt.pix.sizeimage);
+      int gotpacket;
+      sws_scale(swsctx, (const uint8_t*const*)frame->data, frame->linesize, 0, frame->height, dstframe->data, dstframe->linesize);
+      av_init_packet(&packet);
+      packet.data=0;
+packet.size=0;
+      avcodec_encode_video2(ctx, &packet, dstframe, &gotpacket);
+      unsigned char frameinfo=0x22; // Note: differentiating between keyframes and non-keyframes seems to break stuff, so let's just go with all being interframes (1=keyframe, 2=interframe, 3=disposable interframe)
+      dprintf(tc_client_in[1], "/video %i\n", packet.size+1);
+      write(tc_client_in[1], &frameinfo, 1);
+      write(tc_client_in[1], packet.data, packet.size);
+      // Also send the packet to our main thread so we can see ourselves
+      dprintf(campipe[1], "Video: out %i\n", packet.size+1);
+      write(campipe[1], &frameinfo, 1);
+      write(campipe[1], packet.data, packet.size);
+
+      av_free_packet(&packet);
+    }
+    sws_freeContext(swsctx);
+    _exit(0);
+  }
+  close(campipe[1]);
+  GIOChannel* channel=g_io_channel_unix_new(campipe[0]);
+  g_io_channel_set_encoding(channel, 0, 0);
+  g_io_add_watch(channel, G_IO_IN, handledata, data);
+}
+#endif
+
+gboolean handleresize(GtkWidget* widget, GdkEventConfigure* event, struct viddata* data)
+{
+  char bottom=autoscroll_before(data->scroll);
+  if(event->width!=gtk_widget_get_allocated_width(data->box))
+  {
+    updatescaling(data, event->width, 0);
+  }
+  if(bottom){autoscroll_after(data->scroll);}
+  return 0;
+}
+
+void handleresizepane(GObject* obj, GParamSpec* spec, struct viddata* data)
+{
+  char bottom=autoscroll_before(data->scroll);
+  updatescaling(data, 0, gtk_paned_get_position(GTK_PANED(obj)));
+  if(bottom){autoscroll_after(data->scroll);}
+}
+
+gboolean inputkeys(GtkWidget* widget, GdkEventKey* event, void* data)
+{
+  if(event->keyval==GDK_KEY_Up || event->keyval==GDK_KEY_Down){return 1;}
+  if(event->keyval==GDK_KEY_Tab)
+  {
+    // Tab completion
+    int cursor=gtk_editable_get_position(GTK_EDITABLE(widget));;
+    GtkEntryBuffer* buf=gtk_entry_get_buffer(GTK_ENTRY(widget));
+    const char* text=gtk_entry_buffer_get_text(buf);
+    unsigned int namestart=0;
+    unsigned int i;
+    for(i=0; i<cursor; ++i)
+    {
+      if(text[i]==' '){namestart=i+1;}
+    }
+    const char* matches[usercount];
+    unsigned int matchcount=0;
+    unsigned int commonlen=128;
+    for(i=0; i<usercount; ++i)
+    {
+      if(!strncmp(&text[namestart], userlist[i].nick, cursor-namestart))
+      {
+        unsigned int j;
+        for(j=0; j<matchcount; ++j)
+        {
+          if(strncmp(matches[j], userlist[i].nick, commonlen))
+          {
+            for(commonlen=0; userlist[i].nick[commonlen] && matches[j][commonlen] && userlist[i].nick[commonlen]==matches[j][commonlen]; ++commonlen);
+          }
+        }
+        matches[matchcount]=userlist[i].nick;
+        ++matchcount;
+      }
+    }
+    if(matchcount==1)
+    {
+      gtk_entry_buffer_insert_text(buf, cursor, &matches[0][cursor-namestart], -1);
+      cursor+=strlen(&matches[0][cursor-namestart]);
+      if(!namestart){gtk_entry_buffer_insert_text(buf, cursor, ": ", -1); cursor+=2;}
+      gtk_editable_set_position(GTK_EDITABLE(widget), cursor);
+    }
+    else if(matchcount>1)
+    {
+      gtk_entry_buffer_insert_text(buf, cursor, &matches[0][cursor-namestart], commonlen+namestart-cursor);
+      cursor=namestart+commonlen;
+      gtk_editable_set_position(GTK_EDITABLE(widget), cursor);
+    }
+    return 1;
+  }
+  return 0;
+}
+
+void sendmessage(GtkEntry* entry, struct viddata* data)
+{
+  const char* msg=gtk_entry_get_text(entry);
+  char text[strlen("[00:00] ")+strlen("You: ")+strlen(msg)+1];
+  time_t timestamp=time(0);
+  struct tm* t=localtime(&timestamp);
+  sprintf(text, "[%02i:%02i] ", t->tm_hour, t->tm_min);
+  sprintf(&text[8], "You: %s", msg);
+  printchat(data, text);
+  dprintf(tc_client_in[1], "%s\n", msg);
+  gtk_entry_set_text(entry, "");
+}
+
+int main(int argc, char** argv)
+{
+  struct viddata data={0,0,0,0,0};
+  avcodec_register_all();
+  data.vdecoder=avcodec_find_decoder(AV_CODEC_ID_FLV1);
+  data.adecoder=avcodec_find_decoder(AV_CODEC_ID_NELLYMOSER);
+
+#ifdef HAVE_SOUND
+  #if HAVE_SOUND==avresample
+  data.resamplectx=avresample_alloc_context();
+  av_opt_set_int(data.resamplectx, "in_channel_layout", AV_CH_FRONT_CENTER, 0);
+  av_opt_set_int(data.resamplectx, "in_sample_fmt", AV_SAMPLE_FMT_FLT, 0);
+  // TODO: any way to get the sample rate from the frame/decoder? cam->frame->sample_rate seems to be 0
+  av_opt_set_int(data.resamplectx, "in_sample_rate", 11025, 0);
+  av_opt_set_int(data.resamplectx, "out_channel_layout", AV_CH_FRONT_CENTER, 0);
+  av_opt_set_int(data.resamplectx, "out_sample_fmt", AV_SAMPLE_FMT_S16, 0);
+  av_opt_set_int(data.resamplectx, "out_sample_rate", 22050, 0);
+  avresample_open(data.resamplectx);
+  #else
+  data.resamplectx=swr_alloc_set_opts(0, AV_CH_FRONT_CENTER, AV_SAMPLE_FMT_S16, 22050, AV_CH_FRONT_CENTER, AV_SAMPLE_FMT_FLT, 11025, 0, 0);
+  swr_init(data.swrctx);
+  #endif
+  int audiopipe[2];
+  pipe(audiopipe);
+  data.audiopipe=audiopipe[1];
+  if(!fork())
+  {
+    prctl(PR_SET_PDEATHSIG, SIGHUP);
+    close(audiopipe[1]);
+    audiothread(audiopipe[0]);
+    _exit(0);
+  }
+  close(audiopipe[0]);
+#endif
+
+  gtk_init(&argc, &argv);
+  GtkBuilder* gui=gtk_builder_new_from_file("gtkgui.glade");
+  gtk_builder_connect_signals(gui, 0);
+
+#ifdef HAVE_V4L2
+  GtkWidget* item=GTK_WIDGET(gtk_builder_get_object(gui, "menuitem_broadcast_camera"));
+  g_signal_connect(item, "toggled", G_CALLBACK(togglecam), &data);
+  data.vencoder=avcodec_find_encoder(AV_CODEC_ID_FLV1);
+#else
+  GtkWidget* item=GTK_WIDGET(gtk_builder_get_object(gui, "menuitem_broadcast"));
+  gtk_widget_destroy(item);
+#endif
+  
+  data.box=GTK_WIDGET(gtk_builder_get_object(gui, "cambox"));
+  userlistwidget=GTK_WIDGET(gtk_builder_get_object(gui, "userlistbox"));
+  GtkWidget* chatview=GTK_WIDGET(gtk_builder_get_object(gui, "chatview"));
+  data.scroll=gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(gtk_builder_get_object(gui, "chatscroll")));
+
+  data.buffer=gtk_text_view_get_buffer(GTK_TEXT_VIEW(chatview));
+  #define colormap(code, color) gtk_text_buffer_create_tag(data.buffer, code, "foreground", color, (char*)0)
+  colormap("[31", "#821615");
+  colormap("[31;1", "#c53332");
+  colormap("[33", "#a08f23");
+  //colormap("[33", "#a78901");
+  colormap("[33;1", "#919104");
+  colormap("[32;1", "#7bb224");
+  //colormap("[32;1", "#7db257");
+  colormap("[32", "#487d21");
+  colormap("[36", "#00a990");
+  colormap("[34;1", "#32a5d9");
+  //colormap("[34;1", "#1d82eb");
+  colormap("[34", "#1965b6");
+  colormap("[35", "#5c1a7a");
+  colormap("[35;1", "#9d5bb5");
+  //colormap("[35;1", "#c356a3");
+  //colormap("[35;1", "#b9807f");
+
+  GtkWidget* panes=GTK_WIDGET(gtk_builder_get_object(gui, "vpaned"));
+  g_signal_connect(panes, "notify::position", (GCallback)handleresizepane, &data);
+
+  GtkWidget* inputfield=GTK_WIDGET(gtk_builder_get_object(gui, "inputfield"));
+  g_signal_connect(inputfield, "activate", (GCallback)sendmessage, &data);
+  g_signal_connect(inputfield, "key-press-event", (GCallback)inputkeys, &data);
+
+  GtkWidget* window=GTK_WIDGET(gtk_builder_get_object(gui, "main"));
+  g_signal_connect(window, "configure-event", (GCallback)handleresize, &data);
+  gtk_widget_show_all(window);
+
+  pipe(tc_client);
+  pipe(tc_client_in);
+  if(!fork())
+  {
+    prctl(PR_SET_PDEATHSIG, SIGHUP);
+    close(tc_client[0]);
+    close(tc_client_in[1]);
+    dup2(tc_client[1], 1);
+    dup2(tc_client_in[0], 0);
+    argv[0]="./tc_client";
+    execv("./tc_client", argv);
+  }
+  close(tc_client_in[0]);
+  GIOChannel* tcchannel=g_io_channel_unix_new(tc_client[0]);
+  g_io_channel_set_encoding(tcchannel, 0, 0);
+  unsigned int channel_id=g_io_add_watch(tcchannel, G_IO_IN, handledata, &data);
+
+  gtk_main();

+  g_source_remove(channel_id);
+  g_io_channel_shutdown(tcchannel, 0, 0);
+  camera_cleanup();
+#ifdef HAVE_SOUND
+  #if HAVE_SOUND==avresample
+  avresample_free(&data.resamplectx);
+  #else
+  swr_free(&data.swrctx);
+  #endif
+#endif
+  return 0;
+}
diff --git a/utilities/gtk/compat.c b/utilities/gtk/compat.c
new file mode 100644
index 0000000..4b45d2a
--- /dev/null
+++ b/utilities/gtk/compat.c
@@ -0,0 +1,93 @@
+/*
+    tc_client-gtk, a graphical user interface for tc_client
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+#include <unistd.h>
+#include <fcntl.h>
+#include <string.h>
+#include <ctype.h>
+#include <sys/stat.h>
+#include <gtk/gtk.h>
+#include "compat.h"
+
+#if GTK_MAJOR_VERSION<3
+  GtkWidget* gtk_box_new(int vertical, int spacing)
+  {
+    if(vertical)
+    {
+      return gtk_vbox_new(1, spacing);
+    }else{
+      return gtk_hbox_new(1, spacing);
+    }
+  }
+  int gtk_widget_get_allocated_width(GtkWidget* widget)
+  {
+    GtkAllocation alloc;
+    gtk_widget_get_allocation(widget, &alloc);
+    return alloc.width;
+  }
+  int gtk_widget_get_allocated_height(GtkWidget* widget)
+  {
+    GtkAllocation alloc;
+    gtk_widget_get_allocation(widget, &alloc);
+    return alloc.height;
+  }
+  char* newline(char* line)
+  {
+    unsigned int i;
+    for(i=0; line[i] && line[i]!='\n' && line[i]!='\r'; ++i);
+    return &line[i];
+  }
+  // Hack to let us load a glade GUI designed for gtk+-3.x
+  GtkBuilder* gtk_builder_new_from_file(const char* filename)
+  {
+    struct stat st;
+    if(stat(filename, &st)){return 0;}
+    char buf[st.st_size+10];
+    int f=open(filename, O_RDONLY);
+    read(f, buf, st.st_size);
+    close(f);
+    buf[st.st_size]=0;
+    char* pos;
+    if((pos=strstr(buf, "<requires ")))
+    {
+      char* end=newline(pos);
+      memmove(pos, end, strlen(end)+1);
+    }
+    char* orientation;
+    while((orientation=strstr(buf, "<property name=\"orientation\">")))
+    {
+      char dir=toupper(orientation[29]);
+      pos=newline(orientation);
+      memmove(orientation, pos, strlen(pos)+1);
+      pos=orientation;
+      while(pos>buf && strncmp(pos, "class=\"Gtk", 10)){--pos;}
+      if(pos>buf)
+      {
+        memmove(&pos[11], &pos[10], strlen(&pos[10])+1);
+        pos[10]=dir;
+      }
+    }
+    while((pos=strstr(buf, "class=\"GtkBox\"")) || (pos=strstr(buf, "class=\"GtkPaned\"")))
+    {
+      memmove(&pos[11], &pos[10], strlen(&pos[10])+1);
+      pos[10]='H'; // Default is horizontal
+    }
+    GtkBuilder* gui=gtk_builder_new();
+    GError* error=0;
+    if(!gtk_builder_add_from_string(gui, buf, -1, &error)){g_error("%s\n", error->message);}
+    return gui;
+  }
+#endif
diff --git a/utilities/gtk/compat.h b/utilities/gtk/compat.h
new file mode 100644
index 0000000..88004d2
--- /dev/null
+++ b/utilities/gtk/compat.h
@@ -0,0 +1,24 @@
+/*
+    tc_client-gtk, a graphical user interface for tc_client
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+#if GTK_MAJOR_VERSION<3
+  #define GTK_ORIENTATION_HORIZONTAL 0
+  #define GTK_ORIENTATION_VERTICAL 1
+  extern GtkWidget* gtk_box_new(int vertical, int spacing);
+  extern int gtk_widget_get_allocated_width(GtkWidget* widget);
+  extern int gtk_widget_get_allocated_height(GtkWidget* widget);
+  extern GtkBuilder* gtk_builder_new_from_file(const char* filename);
+#endif
diff --git a/utilities/gtk/media.c b/utilities/gtk/media.c
new file mode 100644
index 0000000..e7fbb24
--- /dev/null
+++ b/utilities/gtk/media.c
@@ -0,0 +1,141 @@
+/*
+    tc_client-gtk, a graphical user interface for tc_client
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+#include <stdlib.h>
+#include <string.h>
+#include <gtk/gtk.h>
+#include <libavcodec/avcodec.h>
+#include "media.h"
+struct camera* cams=0;
+unsigned int camcount=0;
+
+#ifdef HAVE_SOUND
+// Experimental mixer, not sure if it really works
+void camera_playsnd(int audiopipe, struct camera* cam, short* samples, unsigned int samplecount)
+{
+  if(cam->samples)
+  {
+// int sources=1;
+    unsigned int i;
+    for(i=0; i<camcount; ++i)
+    {
+      if(!cams[i].samples){continue;}
+      if(cam==&cams[i]){continue;}
+      unsigned j;
+      for(j=0; j<cam->samplecount && j<cams[i].samplecount; ++j)
+      {
+        cam->samples[j]+=cams[i].samples[j];
+      }
+      free(cams[i].samples);
+      cams[i].samples=0;
+// ++sources;
+    }
+    write(audiopipe, cam->samples, cam->samplecount*sizeof(short));
+    free(cam->samples);
+// printf("Mixed sound from %i sources (cam: %p)\n", sources, cam);
+  }
+  cam->samples=malloc(samplecount*sizeof(short));
+  memcpy(cam->samples, samples, samplecount*sizeof(short));
+  cam->samplecount=samplecount;
+}
+#endif
+
+void camera_remove(const char* id)
+{
+  unsigned int i;
+  for(i=0; i<camcount; ++i)
+  {
+    if(!strcmp(cams[i].id, id))
+    {
+      gtk_widget_destroy(cams[i].box);
+      av_frame_free(&cams[i].frame);
+      avcodec_free_context(&cams[i].vctx);
+#ifdef HAVE_SOUND
+      avcodec_free_context(&cams[i].actx);
+#endif
+      free(cams[i].id);
+      free(cams[i].nick);
+      --camcount;
+      memmove(&cams[i], &cams[i+1], (camcount-i)*sizeof(struct camera));
+      break;
+    }
+  }
+}
+
+void camera_removebynick(const char* nick)
+{
+  unsigned int i;
+  for(i=0; i<camcount; ++i)
+  {
+    if(!strcmp(cams[i].nick, nick))
+    {
+      gtk_widget_destroy(cams[i].box);
+      av_frame_free(&cams[i].frame);
+      avcodec_free_context(&cams[i].vctx);
+#ifdef HAVE_SOUND
+      avcodec_free_context(&cams[i].actx);
+#endif
+      free(cams[i].id);
+      free(cams[i].nick);
+      --camcount;
+      memmove(&cams[i], &cams[i+1], (camcount-i)*sizeof(struct camera));
+      break;
+    }
+  }
+}
+
+struct camera* camera_find(const char* id)
+{
+  unsigned int i;
+  for(i=0; i<camcount; ++i)
+  {
+    if(!strcmp(cams[i].id, id)){return &cams[i];}
+  }
+  return 0;
+}
+
+struct camera* camera_findbynick(const char* nick)
+{
+  unsigned int i;
+  for(i=0; i<camcount; ++i)
+  {
+    if(!strcmp(cams[i].nick, nick)){return &cams[i];}
+  }
+  return 0;
+}
+
+struct camera* camera_new(void)
+{
+  ++camcount;
+  cams=realloc(cams, sizeof(struct camera)*camcount);
+  return &cams[camcount-1];
+}
+
+void camera_cleanup(void)
+{
+  unsigned int i;
+  for(i=0; i<camcount; ++i)
+  {
+    av_frame_free(&cams[i].frame);
+    avcodec_free_context(&cams[i].vctx);
+#ifdef HAVE_SOUND
+    avcodec_free_context(&cams[i].actx);
+#endif
+    free(cams[i].id);
+    free(cams[i].nick);
+  }
+  free(cams);
+}
diff --git a/utilities/gtk/media.h b/utilities/gtk/media.h
new file mode 100644
index 0000000..159e998
--- /dev/null
+++ b/utilities/gtk/media.h
@@ -0,0 +1,42 @@
+/*
+    tc_client-gtk, a graphical user interface for tc_client
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+struct camera
+{
+  AVFrame* frame;
+  AVFrame* dstframe;
+  GtkWidget* cam;
+  AVCodecContext* vctx;
+  AVCodecContext* actx;
+  short* samples;
+  unsigned int samplecount;
+  char* id;
+  char* nick;
+  GtkWidget* box; // holds label and cam
+  GtkWidget* label;
+};
+extern struct camera* cams;
+extern unsigned int camcount;
+
+#ifdef HAVE_SOUND
+extern void camera_playsnd(int audiopipe, struct camera* cam, short* samples, unsigned int samplecount);
+#endif
+extern void camera_remove(const char* nick);
+extern void camera_removebynick(const char* nick);
+extern struct camera* camera_find(const char* id);
+extern struct camera* camera_findbynick(const char* nick);
+extern struct camera* camera_new(void);
+extern void camera_cleanup(void);
diff --git a/utilities/gtk/userlist.c b/utilities/gtk/userlist.c
new file mode 100644
index 0000000..89d1df5
--- /dev/null
+++ b/utilities/gtk/userlist.c
@@ -0,0 +1,84 @@
+/*
+    camviewer, a sample application to view tinychat cam streams
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+#include <stdlib.h>
+#include <string.h>
+#include <gtk/gtk.h>
+#include "userlist.h"
+
+struct user* userlist=0;
+unsigned int usercount=0;
+GtkWidget* userlistwidget=0;
+
+struct user* finduser(const char* nick)
+{
+  unsigned int i;
+  for(i=0; i<usercount; ++i)
+  {
+    if(!strcmp(userlist[i].nick, nick)){return &userlist[i];}
+  }
+  return 0;
+}
+
+struct user* adduser(const char* nick)
+{
+  struct user* user=finduser(nick);
+  if(user){return user;} // User already existed (this might happen when running /names)
+  ++usercount;
+  userlist=realloc(userlist, sizeof(struct user)*usercount);
+  userlist[usercount-1].nick=strdup(nick);
+  userlist[usercount-1].label=gtk_label_new(nick); // TODO: some kind of menubutton for actions?
+#if GTK_MAJOR_VERSION>=3
+  gtk_widget_set_halign(userlist[usercount-1].label, GTK_ALIGN_START);
+#endif
+  userlist[usercount-1].ismod=0;
+  gtk_box_pack_start(GTK_BOX(userlistwidget), userlist[usercount-1].label, 0, 0, 0);
+  gtk_widget_show(userlist[usercount-1].label);
+  return &userlist[usercount-1];
+}
+
+void renameuser(const char* old, const char* newnick)
+{
+  struct user* user=finduser(old);
+  if(!user){return;}
+  free(user->nick);
+  user->nick=strdup(newnick);
+  if(user->ismod)
+  {
+    char newlabel[strlen(newnick)+2];
+    newlabel[0]='@';
+    strcpy(&newlabel[1], newnick);
+    gtk_label_set_text(GTK_LABEL(user->label), newlabel);
+  }else{
+    gtk_label_set_text(GTK_LABEL(user->label), newnick);
+  }
+}
+
+void removeuser(const char* nick)
+{
+  unsigned int i;
+  for(i=0; i<usercount; ++i)
+  {
+    if(!strcmp(userlist[i].nick, nick))
+    {
+      free(userlist[i].nick);
+      gtk_widget_destroy(userlist[i].label);
+      --usercount;
+      memmove(&userlist[i], &userlist[i+1], (usercount-i)*sizeof(struct user));
+      return;
+    }
+  }
+}
diff --git a/utilities/gtk/userlist.h b/utilities/gtk/userlist.h
new file mode 100644
index 0000000..98b886e
--- /dev/null
+++ b/utilities/gtk/userlist.h
@@ -0,0 +1,32 @@
+/*
+    camviewer, a sample application to view tinychat cam streams
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+struct user
+{
+  char* nick;
+  GtkWidget* label;
+//  unsigned int id; // hm, tc_client doesn't share IDs other than in guestnicks, this might be useful for a ban-after-they-left situation
+  char ismod;
+};
+
+extern struct user* userlist;
+extern unsigned int usercount;
+extern GtkWidget* userlistwidget;
+
+extern struct user* finduser(const char* nick);
+extern struct user* adduser(const char* nick);
+extern void renameuser(const char* old, const char* newnick);
+extern void removeuser(const char* nick);
diff --git a/utilities/modbot/commands.html b/utilities/modbot/commands.html
index c9477b2..b7b8325 100644
--- a/utilities/modbot/commands.html
+++ b/utilities/modbot/commands.html
@@ -16,6 +16,7 @@
     <tr><td>!wrongrequest</td><td>undo the last request you made</td></tr>
     <tr><td>!requestedby</td><td>see who requested the current video</td></tr>
     <tr><td>!modstats</td><td>get a percentage of how often there are mods in the channel (aside from modbot)</td></tr>
+    <tr><td>!syncvid</td><td>synchronize video position (or see a video that was started while having youtube videos disabled in the flash client)</td></tr>
     <tr><th colspan="2">Mod commands:</th></tr>
     <tr><td>!playnext</td><td>play the next video in queue without approving it (to see if it's ok)</td></tr>
     <tr><td>!approve</td><td>mark the currently playing video as good, or if none is playing the next in queue</td></tr>
diff --git a/utilities/modbot/modbot.c b/utilities/modbot/modbot.c
index 86ed03b..bb193b8 100644
--- a/utilities/modbot/modbot.c
+++ b/utilities/modbot/modbot.c
@@ -499,6 +499,16 @@ int main(int argc, char** argv)
           }
           say(pm, "The channel has had mods %u%% of the time for the past %u %s\n", hasmods, session, timeformat);
         }
+        else if(!strcmp(msg, "!syncvid"))
+        {
+          if(playing)
+          {
+            space[0]=0;
+            say(0, "/priv %s /mbs youTube %s %u\n", nick, playing, (time(0)-started)*1000);
+          }else{
+            say(pm, "Nothing is playing\n");
+          }
+        }
         else if(list_contains(&mods, nick)) // Mods-only commands
         {
           if(!strcmp(msg, "!playnext"))