+-> fix remote picture previewer
+-> add a download option in media list
+-> add a previewer for movies
 -> make camera frames positions configurable
 -> add a view log functionality
--> add a previewer for snapshots
--> add a previewer for movies
 
 -> style scroll bars
 -> hint text next to section titles
 
 import base64
 import json
 import logging
+import os
 
 from tornado.web import RequestHandler, HTTPError, asynchronous
 
         elif op == 'download':
             self.download(camera_id, filename)
         
+        elif op == 'preview':
+            self.preview(camera_id, filename)
+        
         else:
             raise HTTPError(400, 'unknown operation')
     
                         camera_config.get('@host'),
                         camera_config.get('@port'),
                         camera_config.get('@remote_camera_id'))
-                
+
                 camera_full_url = camera_config['@proto'] + '://' + camera_url
-                
+
                 if remote_list is None:
                     return self.finish_json({'error': 'Failed to get picture list for %(url)s.' % {
                             'url': camera_full_url}})
         else:
             pictures = mediafiles.list_pictures(camera_config)
             
-            self.finish_json({'pictures': pictures})
+            self.finish_json({
+                'mediaList': pictures,
+                'cameraName': camera_config['@name']
+            })
         
     @BaseHandler.auth()
     def download(self, camera_id, filename):
         logging.debug('downloading picture %(filename)s of camera %(id)s' % {
                 'filename': filename, 'id': camera_id})
         
-        # TODO implement me
+        if camera_id not in config.get_camera_ids():
+            raise HTTPError(404, 'no such camera')
         
-        self.finish_json()
+        camera_config = config.get_camera(camera_id)
+        if camera_config['@proto'] != 'v4l2':
+            def on_response(remote_list):
+                camera_url = remote.make_remote_camera_url(
+                        camera_config.get('@host'),
+                        camera_config.get('@port'),
+                        camera_config.get('@remote_camera_id'))
+                
+                camera_full_url = camera_config['@proto'] + '://' + camera_url
+                
+                if remote_list is None:
+                    return self.finish_json({'error': 'Failed to get picture list for %(url)s.' % {
+                            'url': camera_full_url}})
+
+                self.finish_json(remote_list)
+            
+            remote.download_picture(
+                    camera_config.get('@host'),
+                    camera_config.get('@port'),
+                    camera_config.get('@username'),
+                    camera_config.get('@password'),
+                    camera_config.get('@remote_camera_id'), on_response)
+        
+        else:
+            content = mediafiles.get_media_content(camera_config, filename)
+            
+            pretty_filename = camera_config['@name'] + '_' + os.path.basename(filename)
+            self.set_header('Content-Type', 'image/jpeg')
+            self.set_header('Content-Disposition', 'attachment; filename=' + pretty_filename + ';')
+            
+            self.finish(content)
+
+
+    @BaseHandler.auth()
+    def preview(self, camera_id, filename):
+        logging.debug('previewing picture %(filename)s of camera %(id)s' % {
+                'filename': filename, 'id': camera_id})
+        
+        if camera_id not in config.get_camera_ids():
+            raise HTTPError(404, 'no such camera')
+        
+        camera_config = config.get_camera(camera_id)
+        if camera_config['@proto'] != 'v4l2':
+            def on_response(response):
+                camera_url = remote.make_remote_camera_url(
+                        camera_config.get('@host'),
+                        camera_config.get('@port'),
+                        camera_config.get('@remote_camera_id'))
+                
+                camera_full_url = camera_config['@proto'] + '://' + camera_url
+                
+                if response is None:
+                    return self.finish_json({'error': 'Failed to get picture list for %(url)s.' % {
+                            'url': camera_full_url}})
+
+                self.set_header('Content-Type', 'image/jpeg')
+                self.finish(response)
+            
+            remote.preview_picture(
+                    camera_config.get('@host'),
+                    camera_config.get('@port'),
+                    camera_config.get('@username'),
+                    camera_config.get('@password'),
+                    camera_config.get('@remote_camera_id'),
+                    filename,
+                    width=self.get_argument('width', None),
+                    height=self.get_argument('height', None),
+                    callback=on_response)
+        
+        else:
+            content = mediafiles.get_media_content(camera_config, filename)
+            
+            # TODO add support for ?width=, ?height=
+        
+            self.set_header('Content-Type', 'image/jpeg')
+            self.finish(content)
 
 
 class MovieHandler(BaseHandler):
 
 import os.path
 
 import config
+import utils
 
 
 _PICTURE_EXTS = ['.jpg']
 #         return []
 
     full_paths = _list_media_files(target_dir, exts=_PICTURE_EXTS)
-    picture_files = [p[len(target_dir):] for p in full_paths]
+    picture_files = []
+    
+    for p in full_paths:
+        path = p[len(target_dir):]
+        if not path.startswith('/'):
+            path = '/' + path
+        
+        picture_files.append({
+            'path': path,
+            'momentStr': utils.pretty_date_time(datetime.datetime.fromtimestamp(os.path.getmtime(p))),
+            'timestamp': os.path.getmtime(p)
+        })
     
     # TODO files listed here may not belong to the given camera
     
     target_dir = camera_config.get('target_dir')
 
     full_paths = _list_media_files(target_dir, exts=_MOVIE_EXTS)
-    movie_files = [p[len(target_dir):] for p in full_paths]
+    movie_files = [{
+        'path': p[len(target_dir):],
+        'momentStr': utils.pretty_date_time(datetime.datetime.fromtimestamp(os.path.getmtime(p))),
+        'timestamp': os.path.getmtime(p)
+    } for p in full_paths]
     
     # TODO files listed here may not belong to the given camera
     
     return movie_files
+
+
+def get_media_content(camera_config, path):
+    target_dir = camera_config.get('target_dir')
+
+    full_path = os.path.join(target_dir, path)
+    
+    try:
+        with open(full_path) as f:
+            return f.read()
+    
+    except Exception as e:
+        logging.error('failed to read file %(path)s: %(msg)s' % {
+                'path': full_path, 'msg': unicode(e)})
+        
+        return None
 
             
             return callback(None)
         
-        return callback(response['pictures'])
+        return callback(response)
     
     http_client = AsyncHTTPClient()
     http_client.fetch(request, on_response)
+
+
+def preview_picture(host, port, username, password, camera_id, filename, width, height, callback):
+    logging.debug('getting preview for file %(filename)s of remote camera %(id)s on %(host)s:%(port)s' % {
+            'filename': filename,
+            'id': camera_id,
+            'host': host,
+            'port': port})
+    
+    uri = '/picture/%(id)s/preview/%(filename)s/?' % {
+            'id': camera_id,
+            'filename': filename}
+    
+    if width:
+        uri += 'width=' + str(width)
+    if height:
+        uri += 'height=' + str(height)
+    
+    request = _make_request(host, port, username, password, uri)
+    
+    def on_response(response):
+        if response.error:
+            logging.error('failed to get preview for file %(filename)s of remote camera %(id)s on %(host)s:%(port)s: %(msg)s' % {
+                    'filename': filename,
+                    'id': camera_id,
+                    'host': host,
+                    'port': port,
+                    'msg': unicode(response.error)})
+            
+            return callback(None)
+        
+        return callback(response.body)
+
+    http_client = AsyncHTTPClient()
+    http_client.fetch(request, on_response)
 
         (r'^/config/(?P<camera_id>\d+)/(?P<op>get|set|rem|set_preview)/?$', handlers.ConfigHandler),
         (r'^/config/(?P<op>add|list|list_devices)/?$', handlers.ConfigHandler),
         (r'^/picture/(?P<camera_id>\d+)/(?P<op>current|list)/?$', handlers.PictureHandler),
-        (r'^/picture/(?P<camera_id>\d+)/(?P<op>download)/(?P<filename>.+)/?$', handlers.PictureHandler),
+        (r'^/picture/(?P<camera_id>\d+)/(?P<op>download|preview)/(?P<filename>.+)/?$', handlers.PictureHandler),
         (r'^/movie/(?P<camera_id>\d+)/(?P<op>list)/?$', handlers.MovieHandler),
         (r'^/movie/(?P<camera_id>\d+)/(?P<op>download)/(?P<filename>.+)/?$', handlers.MovieHandler),
         (r'^/update/?$', handlers.UpdateHandler),
 
 
     text = u'{day} {month} {year}, {hm}'.format(
         day=date_time.day,
-        month=_(date_time.strftime('%B')),
+        month=date_time.strftime('%B'),
         year=date_time.year,
         hm=date_time.strftime('%H:%M')
     )
 
 }
 
 
+div.media-dialog {
+    overflow: auto;
+}
+
+div.media-list-group-title {
+    background-color: #313131;
+    font-size: 1.3em;
+    font-weight: bold;
+    text-align: center;
+    padding: 1em 0px 0.2em 0px;
+}
+
+div.media-list-entry {
+    height: 4em;
+    background-color: #414141;
+    border-bottom: 1px solid #313131;
+    cursor: pointer;
+    transition: background-color 0.1s linear;
+}
+
+div.media-list-entry:HOVER {
+    background-color: #494949;
+}
+
+div.media-list-entry:ACTIVE {
+    background-color: #3b3b3b;
+}
+
+img.media-list-preview {
+    float: left;
+    height: 3em;
+    margin: 0.45em;
+    border: 1px solid #212121;
+    box-shadow: 1px 1px 6px rgba(0,0,0,0.3);
+}
+
+div.media-list-entry-name {
+    font-size: 1.3em;
+    padding: 0.4em 0em;
+    text-align: center;
+    white-space: nowrap;
+}
+
+div.media-list-entry-moment {
+    font-size: 1em;
+    text-align: center;
+    white-space: nowrap;
+}
+
+div.picture-dialog-content {
+    position: relative;
+}
+
+div.picture-dialog-prev-arrow,
+div.picture-dialog-next-arrow {
+    position: absolute;
+    top: 45%;
+    background-color: rgba(0, 0, 0, 0.3);
+    background-image: url(../img/arrows.svg);
+    background-size: cover;
+    width: 3em;
+    height: 3em;
+    border-radius: 0.3em;
+    -webkit-user-select: none;
+    -moz-user-select: none;
+    user-select: none;
+    cursor: pointer;
+}
+
+div.picture-dialog-prev-arrow {
+    left: 1em;
+}
+
+div.picture-dialog-next-arrow {
+    right: 1em;
+    background-position: -100% 0%;
+}
+
+img.picture-dialog-content {
+    border: 1px solid #292929;
+}
+
+
     /* camera frames */
 
 div.camera-list {
 
 div.camera-top-bar {
     padding: 3px 0px;
-    font-size: 17px;
-    height: 17px;
+    font-size: 20px;
+    height: 25px;
 }
 
 div.modal-container div.camera-top-bar {
 
 span.camera-name {
     float: left;
+    line-height: 25px;
 }
 
 div.camera-buttons {
 
 div.camera-button {
     display: inline-block;
-    width: 16px;
-    height: 16px;
+    width: 24px;
+    height: 24px;
     background-image: url(../img/top-bar-buttons.svg);
+    background-size: cover;
     margin-left: 3px;
     cursor: pointer;
-    opacity: 1;
     transition: all 0.1s linear;
 }
 
     background-position: -100% 0px;
 }
 
+div.camera-button.media-pictures {
+    background-position: -300% 0px;
+}
+
+div.camera-button.media-movies {
+    background-position: -400% 0px;
+}
+
 div.camera-container {
     position: relative;
     padding: 0px;
         width: 24px;
         height: 24px;
     }
-    
-    div.camera-top-bar {
-        font-size: 20px;
-        height: 25px;
-    }
 }
 
 @media all and (max-width: 1900px) {
 
 }
 
 div.button.mouse-effect {
-    opacity: 0.9;
+    opacity: 0.7;
     transition: opacity 0.1s linear;
 }
 
 }
 
 div.button.mouse-effect:ACTIVE {
-    opacity: 0.6;
+    opacity: 0.7;
 }
 
 
 
 span.modal-title {
     color: white;
+    font-size: 1.2em;
+    line-height: 1.2em;
 }
 
 div.modal-close-button {
     position: absolute;
-    top: 0.3em;
+    top: 0.2em;
     right: 0.3em;
-    width: 16px;
-    height: 16px;
+    width: 1.1em;
+    height: 1.1em;
     background-image: url(../img/top-bar-buttons.svg);
-    opacity: 1;
+    background-size: cover;
     cursor: pointer;
 }
 
     width: 100%;
     margin: auto;
     text-align: center;
+    table-layout: fixed;
     -webkit-user-select: none;
     -moz-user-select: none;
     user-select: none;
 
--- /dev/null
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   width="96"
+   height="48"
+   id="svg2"
+   version="1.1"
+   inkscape:version="0.48.4 r9939"
+   sodipodi:docname="arrows.svg"
+   inkscape:export-filename="/media/data/projects/motioneye/static/img/settings.png"
+   inkscape:export-xdpi="90"
+   inkscape:export-ydpi="90">
+  <defs
+     id="defs4" />
+  <sodipodi:namedview
+     id="base"
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1.0"
+     inkscape:pageopacity="0.0"
+     inkscape:pageshadow="2"
+     inkscape:zoom="5.0625"
+     inkscape:cx="43.687152"
+     inkscape:cy="14.670355"
+     inkscape:document-units="px"
+     inkscape:current-layer="layer1"
+     showgrid="false"
+     inkscape:window-width="1920"
+     inkscape:window-height="1030"
+     inkscape:window-x="0"
+     inkscape:window-y="25"
+     inkscape:window-maximized="1"
+     inkscape:snap-bbox="true"
+     inkscape:bbox-nodes="true"
+     inkscape:bbox-paths="true"
+     inkscape:snap-bbox-edge-midpoints="true"
+     inkscape:snap-bbox-midpoints="true"
+     inkscape:object-paths="true"
+     inkscape:object-nodes="true"
+     inkscape:snap-smooth-nodes="true"
+     inkscape:snap-midpoints="true"
+     inkscape:snap-object-midpoints="true"
+     inkscape:snap-center="true"
+     inkscape:snap-page="true"
+     inkscape:snap-intersection-paths="true"
+     showguides="true"
+     inkscape:guide-bbox="true" />
+  <metadata
+     id="metadata7">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <g
+     inkscape:label="Layer 1"
+     inkscape:groupmode="layer"
+     id="layer1"
+     transform="translate(0,-1004.3622)">
+    <path
+       style="fill:none;stroke:#3498db;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+       d="m 30,1012.3622 -17,17 17,15"
+       id="path3755"
+       inkscape:connector-curvature="0"
+       sodipodi:nodetypes="ccc" />
+    <path
+       sodipodi:nodetypes="ccc"
+       inkscape:connector-curvature="0"
+       id="path3757"
+       d="m 66,1012.3622 17,17 -17,15"
+       style="fill:none;stroke:#3498db;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
+  </g>
+</svg>
 
    sodipodi:docname="motioneye.svg"><metadata
      id="metadata8"><rdf:RDF><cc:Work
          rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
-           rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
      id="defs6" /><sodipodi:namedview
      pagecolor="#969696"
      bordercolor="#666666"
      inkscape:pageopacity="0.70196078"
      inkscape:pageshadow="2"
      inkscape:window-width="1920"
-     inkscape:window-height="1027"
+     inkscape:window-height="1030"
      id="namedview4"
      showgrid="false"
-     inkscape:zoom="5.0625"
+     inkscape:zoom="16.354248"
      inkscape:cx="47.677361"
      inkscape:cy="27.810209"
      inkscape:window-x="0"
        sodipodi:cy="96.59259"
        sodipodi:rx="63.407406"
        sodipodi:ry="60.444443"
-       d="m 228.14814,96.59259 a 63.407406,60.444443 0 1 1 -126.81481,0 63.407406,60.444443 0 1 1 126.81481,0 z"
+       d="m 228.14814,96.59259 c 0,33.38254 -28.38846,60.44444 -63.4074,60.44444 -35.01895,0 -63.40741,-27.0619 -63.40741,-60.44444 0,-33.382544 28.38846,-60.444442 63.40741,-60.444442 35.01894,0 63.4074,27.061898 63.4074,60.444442 z"
        transform="matrix(0.25864487,0,0,-0.27132354,-23.409347,-2.592158)" /><path
        style="font-size:medium;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-indent:0;text-align:start;text-decoration:none;line-height:normal;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;text-anchor:start;baseline-shift:baseline;color:#000000;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:8.06000042;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate;font-family:Sans;-inkscape-font-specification:Sans"
        d="m 19.344,27.142 c -2.295613,0 -4.348358,-0.983485 -5.661,-2.502 l -6.363,0 C 5.92392,24.64 4.8,23.51608 4.8,22.12 l 0,-10.8 C 4.8,9.92392 5.92392,8.8 7.32,8.8 l 23.76,0 c 1.39608,0 2.52,1.12392 2.52,2.52 l 0,10.8 c 0,1.39608 -1.12392,2.52 -2.52,2.52 l -0.936,0 0,0.324 a 1.440144,1.440144 0 1 1 -2.88,0 l 0,-0.324 -2.259,0 c -1.308955,1.518515 -3.365386,2.502 -5.661,2.502 z m -1.152,-1.062 2.304,0 c 0.39888,0 0.72,-0.32112 0.72,-0.72 l 0,-0.288 c 0,-0.39888 -0.32112,-0.72 -0.72,-0.72 l -2.304,0 c -0.39888,0 -0.72,0.32112 -0.72,0.72 l 0,0.288 c 0,0.39888 0.32112,0.72 0.72,0.72 z m 1.152,-4.32 c 2.942573,0 5.328,-2.385427 5.328,-5.328 0,-2.942573 -2.385427,-5.328 -5.328,-5.328 -2.942573,0 -5.328,2.385427 -5.328,5.328 0,2.942573 2.385427,5.328 5.328,5.328 z"
 
    xmlns="http://www.w3.org/2000/svg"
    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
-   width="48"
+   width="80"
    height="16"
    id="svg2"
    version="1.1"
      id="defs4" />
   <sodipodi:namedview
      id="base"
-     pagecolor="#ffffff"
+     pagecolor="#b39a9a"
      bordercolor="#666666"
      borderopacity="1.0"
-     inkscape:pageopacity="0.0"
+     inkscape:pageopacity="0.42745098"
      inkscape:pageshadow="2"
-     inkscape:zoom="1"
-     inkscape:cx="35.119687"
-     inkscape:cy="7.4678179"
+     inkscape:zoom="2.25"
+     inkscape:cx="111.39282"
+     inkscape:cy="5.668431"
      inkscape:document-units="px"
      inkscape:current-layer="layer1"
      showgrid="false"
      inkscape:window-width="1920"
-     inkscape:window-height="1027"
+     inkscape:window-height="1030"
      inkscape:window-x="0"
      inkscape:window-y="25"
      inkscape:window-maximized="1"
        id="path3761"
        d="m 36.5,1041.8622 7,0 0,5 -7,0 z"
        style="fill:none;stroke:#3498db;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
+    <path
+       style="font-size:medium;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-indent:0;text-align:start;text-decoration:none;line-height:normal;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;text-anchor:start;baseline-shift:baseline;color:#000000;fill:#3498db;fill-opacity:1;stroke:none;stroke-width:8.06000042;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate;font-family:Sans;-inkscape-font-specification:Sans"
+       d="m 56.025,1041.1703 c -0.717378,0 -1.358863,0.3074 -1.769063,0.7819 l -1.988437,0 c -0.436275,0 -0.7875,0.3512 -0.7875,0.7875 l 0,3.375 c 0,0.4363 0.351225,0.7875 0.7875,0.7875 l 7.425,0 c 0.436275,0 0.7875,-0.3512 0.7875,-0.7875 l 0,-3.375 c 0,-0.4363 -0.351225,-0.7875 -0.7875,-0.7875 l -0.2925,0 0,-0.1012 a 0.450045,0.450045 0 1 0 -0.9,0 l 0,0.1012 -0.705938,0 c -0.409047,-0.4745 -1.051682,-0.7819 -1.769062,-0.7819 z m -0.36,0.3319 0.72,0 c 0.12465,0 0.225,0.1004 0.225,0.225 l 0,0.09 c 0,0.1247 -0.10035,0.225 -0.225,0.225 l -0.72,0 c -0.12465,0 -0.225,-0.1003 -0.225,-0.225 l 0,-0.09 c 0,-0.1246 0.10035,-0.225 0.225,-0.225 z m 0.36,1.35 c 0.919555,0 1.665,0.7455 1.665,1.665 0,0.9196 -0.745445,1.665 -1.665,1.665 -0.919553,0 -1.665,-0.7454 -1.665,-1.665 0,-0.9195 0.745447,-1.665 1.665,-1.665 z"
+       id="path3803"
+       inkscape:connector-curvature="0" />
+    <path
+       sodipodi:type="arc"
+       style="fill:none;stroke:#3498db;stroke-width:0.84620845;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+       id="path3815"
+       sodipodi:cx="7.8036885"
+       sodipodi:cy="8.2353296"
+       sodipodi:rx="5.6186557"
+       sodipodi:ry="5.3845448"
+       d="m 13.422344,8.2353296 a 5.6186557,5.3845448 0 1 1 -11.2373112,0 5.6186557,5.3845448 0 1 1 11.2373112,0 z"
+       transform="matrix(-1.1568604,0,0,1.2071587,65.027778,1034.4209)" />
+    <path
+       transform="matrix(-1.1568604,0,0,1.2071587,81.027778,1034.4209)"
+       d="m 13.422344,8.2353296 a 5.6186557,5.3845448 0 1 1 -11.2373112,0 5.6186557,5.3845448 0 1 1 11.2373112,0 z"
+       sodipodi:ry="5.3845448"
+       sodipodi:rx="5.6186557"
+       sodipodi:cy="8.2353296"
+       sodipodi:cx="7.8036885"
+       id="path3817"
+       style="fill:none;stroke:#3498db;stroke-width:0.84620845;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
+       sodipodi:type="arc" />
+    <path
+       style="fill:#3498db;fill-opacity:1;stroke:#3498db;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
+       d="m 69.5,1040.8622 6,3.5 -6,3.5 z"
+       id="path3819"
+       inkscape:connector-curvature="0"
+       sodipodi:nodetypes="cccc" />
   </g>
 </svg>
 
     return mapped;
 };
 
+Array.prototype.sortKey = function (keyFunc) {
+    this.sort(function (e1, e2) {
+        var k1 = keyFunc(e1);
+        var k2 = keyFunc(e2);
+        
+        if (k1 < k2) {
+            return -1;
+        }
+        else if (k1 > k2) {
+            return 1;
+        }
+        else {
+            return 0;
+        }
+    });
+};
+
 
     /* UI initialization */
 
     runModalDialog({title: message, buttons: 'yesno', onYes: onYes});
 }
 
+function runPictureDialog(entries, pos) {
+    var content = $('<div class="picture-dialog-content"></div>');
+    
+    var img = $('<img class="picture-dialog-content">');
+    content.append(img);
+    
+    var prevArrow = $('<div class="picture-dialog-prev-arrow button mouse-effect" title="previous picture"></div>');
+    content.append(prevArrow);
+    
+    var nextArrow = $('<div class="picture-dialog-next-arrow button mouse-effect" title="next picture"></div>');
+    content.append(nextArrow);
+    
+    var windowWidth = $(window).width();
+    
+    function updatePicture() {
+        var entry = entries[pos];
+        var width; 
+        if (windowWidth <= 1000) {
+            width = parseInt(windowWidth * 0.9);
+        }
+        else {
+            width = parseInt(windowWidth * 0.5);
+        }
+        
+        img.width(width);
+        img.attr('src', '/picture/' + entry.cameraId + '/preview' + entry.path + '?width=' + width);
+        
+        prevArrow.css('display', pos > 0 ? '' : 'none');
+        nextArrow.css('display', pos < entries.length - 1 ? '' : 'none');
+        
+        $('div.modal-container').find('span.modal-title:last').html(entry.name);
+    }
+    
+    prevArrow.click(function () {
+        if (pos > 0) {
+            pos--;
+        }
+        
+        updatePicture();
+    });
+    
+    nextArrow.click(function () {
+        if (pos < entries.length - 1) {
+            pos++;
+        }
+        
+        updatePicture();
+    });
+    
+    img.load(updateModalDialogPosition);
+    
+    runModalDialog({
+        title: ' ',
+        closeButton: true,
+        buttons: [
+            {caption: 'Close'},
+            {caption: 'Download', isDefault: true, click: function () {
+                window.location.href = img.attr('src').replace('preview', 'download'); 
+                
+                return false;
+            }}
+        ],
+        content: content,
+        stack: true,
+        onShow: updatePicture
+    });
+}
+
 function runAddCameraDialog() {
     if (!$('#motionEyeSwitch')[0].checked) {
         return runAlertDialog('Please enable motionEye first!');
 }
 
 
+function runMediaDialog(cameraId, mediaType) {
+    var dialogDiv = $(
+            '<div class="media-dialog">' +
+            '</div>');
+    
+    var windowWidth = $(window).width();
+    
+    if (windowWidth <= 1000) {
+        dialogDiv.width(parseInt(windowWidth * 0.9));
+        dialogDiv.height(parseInt(windowWidth * 0.9 * 480 / 640));
+    }
+    else {
+        dialogDiv.width(parseInt(windowWidth * 0.5));
+        dialogDiv.height(parseInt(windowWidth * 0.5 * 480 / 640));
+    }
+    
+    function fetchMedia() {
+        var progress = $('<div style="text-align: center; margin: 2px;"><img src="' + staticUrl + 'img/small-progress.gif"></div>');
+        
+        cameraSelect.hide();
+        cameraSelect.before(progress);
+        cameraSelect.parent().find('div').remove(); /* remove any previous progress div */
+        
+        var data = {
+            host: hostEntry.val(),
+            port: portEntry.val(),
+            username: usernameEntry.val(),
+            password: passwordEntry.val()
+        };
+        
+        ajax('GET', '/config/list/', data, function (data) {
+            if (data == null || data.error) {
+                progress.remove();
+                if (passwordEntry.val()) { /* only show an error message when a password is supplied */
+                    showErrorMessage(data && data.error);
+                }
+                
+                return;
+            }
+            
+            cameraSelect.html('');
+            progress.remove();
+            
+            if (data.error || !data.cameras) {
+                return;
+            }
+
+            data.cameras.forEach(function (info) {
+                cameraSelect.append('<option value="' + info.id + '">' + info.name + '</option>');
+            });
+            
+            cameraSelect.show();
+        });
+    }
+    
+    showModalDialog('<div class="modal-progress"></div>');
+    
+    /* fetch the media list */
+    ajax('GET', '/' + mediaType + '/' + cameraId + '/list/', null, function (data) {
+        if (data == null || data.error) {
+            hideModalDialog();
+            showErrorMessage(data && data.error);
+            return;
+        }
+        
+        /* sort and group the media */
+        var groups = {};
+        data.mediaList.forEach(function (media) {
+            var path = media.path;
+            var parts = path.split('/');
+            var keyParts = parts.splice(0, parts.length - 1);
+            var key = keyParts.join('/');
+            var list = (groups[key] = groups[key] || []);
+            
+            list.push({
+                'path': path,
+                'group': key,
+                'name': parts[parts.length - 1],
+                'cameraId': cameraId,
+                'momentStr': media.momentStr,
+                'timestamp': media.timestamp
+            });
+        });
+        
+        var keys = Object.keys(groups);
+        keys.sort();
+        
+        keys.forEach(function (key) {
+            if (key) {
+                var groupDiv = $('<div class="media-list-group-title">' + key + '</div>');
+                dialogDiv.append(groupDiv);
+            }
+            
+            var entries = groups[key];
+            entries.sortKey(function (e) {return e.timestamp;});
+            
+            entries.forEach(function (entry, pos) {
+                var entryDiv = $('<div class="media-list-entry"></div>');
+                
+                var previewImg = $('<img class="media-list-preview" src="/' + mediaType + '/' + cameraId + '/preview' + entry.path + '"/>');
+                entryDiv.append(previewImg);
+                
+                var nameDiv = $('<div class="media-list-entry-name">' + entry.name + '</div>');
+                entryDiv.append(nameDiv);
+                
+                var momentDiv = $('<div class="media-list-entry-moment">' + entry.momentStr + '</div>');
+                entryDiv.append(momentDiv);
+                entryDiv.click(function () {
+                    if (mediaType === 'picture') {
+                        runPictureDialog(entries, pos);
+                    }
+                });
+                
+                dialogDiv.append(entryDiv);
+            });
+        });
+        
+        /* scroll to bottom */
+        dialogDiv.find('img.media-list-preview:last').load(function () {
+            dialogDiv.scrollTop(dialogDiv.prop('scrollHeight'));
+        });
+        
+        var title;
+        if (mediaType === 'picture') {
+            title = 'Pictures taken by ' + data.cameraName;
+        }
+        else {
+            title = 'Movies recored by ' + data.cameraName;
+        }
+        
+        runModalDialog({
+            title: title,
+            closeButton: true,
+            buttons: '',
+            content: dialogDiv,
+            onShow: function () {
+                dialogDiv.scrollTop(dialogDiv.prop('scrollHeight'));
+            }
+        });
+    });
+}
+
+
     /* camera frames */
 
 function addCameraFrameUi(cameraId, cameraName, framerate) {
                 '<div class="camera-top-bar">' +
                     '<span class="camera-name"></span>' +
                     '<div class="camera-buttons">' +
+                        '<div class="button camera-button mouse-effect media-pictures" title="pictures"></div>' +
+                        '<div class="button camera-button mouse-effect media-movies" title="movies"></div>' +
                         '<div class="button camera-button mouse-effect configure" title="configure"></div>' +
-                        '<div class="button camera-button mouse-effect full-screen" title="full screen"></div>' +
+//                        '<div class="button camera-button mouse-effect full-screen" title="full screen"></div>' +
                     '</div>' +
                 '</div>' +
                 '<div class="camera-container">' +
     var nameSpan = cameraFrameDiv.find('span.camera-name');
     var configureButton = cameraFrameDiv.find('div.camera-button.configure');
     var fullScreenButton = cameraFrameDiv.find('div.camera-button.full-screen');
+    var picturesButton = cameraFrameDiv.find('div.camera-button.media-pictures');
+    var moviesButton = cameraFrameDiv.find('div.camera-button.media-movies');
     var cameraPlaceholder = cameraFrameDiv.find('div.camera-placeholder');
     var cameraProgress = cameraFrameDiv.find('div.camera-progress');
     var cameraImg = cameraFrameDiv.find('img.camera');
         };
     }(cameraId));
     
+    picturesButton.click(function (cameraId) {
+        return function () {
+            runMediaDialog(cameraId, 'picture');
+        };
+    }(cameraId));
+    
+    moviesButton.click(function (cameraId) {
+        return function () {
+            runMediaDialog(cameraId, 'movie');
+        };
+    }(cameraId));
+    
     /* error and load handlers */
     cameraImg.error(function () {
         this.error = true;
     var windowAspectRatio = windowWidth / windowHeight;
     var frameIndex = cameraFrameDiv.index();
     var pageContainer = $('div.page-container');
+    
+    if (frameImg.hasClass('error')) {
+        return; /* no full screen for erroneous cameras */
+    }
 
     fullScreenCameraId = cameraId;
 
 
 
+var _modalDialogContexts = [];
+
 
     /* UI widgets */
 
 
     /* modal dialog */
 
-function showModalDialog(content, onClose) {
+function showModalDialog(content, onClose, onShow, stack) {
     var glass = $('div.modal-glass');
     var container = $('div.modal-container');
     
     if (container.is(':animated')) {
         return setTimeout(function () {
-            showModalDialog(content, onClose);
+            showModalDialog(content, onClose, onShow, stack);
         }, 100);
     }
     
-    if (container.is(':visible')) {
+    if (container.is(':visible') && stack) {
         /* the modal dialog is already visible,
          * we just replace the content */
         
-        if (container[0]._onClose) {
-            container[0]._onClose();
-        }
+        var children = container.children(':visible');
+        _modalDialogContexts.push({
+            children: children,
+            onClose: container[0]._onClose,
+        });
+        
+        children.css('display', 'none');
         
-        container[0]._onClose = onClose; /* remember the onClose handler */
-        container.html(content);
+        container[0]._onClose = onClose; /* set the new onClose handler */
+        container.append(content);
         updateModalDialogPosition();
         
+        if (onShow) {
+            onShow();
+        }
+        
         return;
     }
     
     container.css('display', 'block');
     updateModalDialogPosition();
     container.animate({'opacity': '1'}, 200);
+    
+    if (onShow) {
+        onShow();
+    }
 }
 
 function hideModalDialog() {
         }, 100);
     }
     
+    if (_modalDialogContexts.length) {
+        if (container[0]._onClose) {
+            container[0]._onClose();
+        }
+        
+        container.children(':visible').remove();
+        
+        var context = _modalDialogContexts.pop();
+        context.children.css('display', '');
+        container[0]._onClose = context.onClose;
+        updateModalDialogPosition();
+        
+        return;
+    }
+    
     glass.animate({'opacity': '0'}, 200, function () {
         glass.css('display', 'none');
     });
     
     var windowWidth = $(window).width();
     var windowHeight = $(window).height();
-    var modalWidth = container.width();
-    var modalHeight = container.height();
+    var modalWidth = container.width() + 10 /* the margins */;
+    var modalHeight = container.height() + 10 /* the margins */;
     
     container.css('left', Math.floor((windowWidth - modalWidth) / 2));
     container.css('top', Math.floor((windowHeight - modalHeight) / 2));
         if (info.click) {
             var oldClick = info.click;
             info.click = function () {
-                hideModalDialog();
-                
                 if (oldClick() == false) {
                     return;
                 }
+                
+                hideModalDialog();
             };
         }
         else {
      * * onOk: Function
      * * onCancel: Function
      * * onClose: Function
+     * * onShow: Function
+     * * stack: Boolean
      */
     
     var content = $('<div></div>');
     }
     
     var handleKeyUp = function (e) {
+        if (!content.is(':visible')) {
+            return;
+        }
+        
         switch (e.which) {
             case 13:
-                if (defaultClick) {
-                    if (defaultClick() == false) {
-                        return;
-                    };
+                if (defaultClick && defaultClick() == false) {
+                    return;
                 }
                 /* intentionally no break */
            
     $('html').bind('keyup', handleKeyUp);
     
     /* and finally, show the dialog */
-    showModalDialog(content, onClose);
+
+    showModalDialog(content, onClose, options.onShow, options.stack);
     
     /* focus the default button if nothing else is focused */
     if (content.find('*:focus').length === 0) {