├── .gitignore ├── tools ├── .gitignore └── README.md ├── http_singlefile.go ├── ui ├── assets │ ├── package.json │ ├── gulpfile.js │ ├── css │ │ └── app.css │ ├── sass │ │ └── app.scss │ ├── img │ │ └── loader.svg │ ├── jsx │ │ └── app.jsx │ └── js │ │ └── app.js └── index.html ├── http_debug.go ├── debug.go ├── README.md ├── http_frame.go ├── http_util.go ├── http_stream.go ├── main.go ├── LICENSE.txt ├── http_playlists.go ├── http_list.go └── video_info.go /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .sass-cache 3 | node_modules 4 | videos 5 | -------------------------------------------------------------------------------- /tools/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | !README.md -------------------------------------------------------------------------------- /tools/README.md: -------------------------------------------------------------------------------- 1 | This directory should contain the two programs: 2 | 3 | - ffmpeg 4 | - ffprobe 5 | 6 | You can download statically linked versions from: 7 | 8 | http://www.ffmpeg.org/download.html 9 | 10 | or symlink/copy your custom versions. -------------------------------------------------------------------------------- /http_singlefile.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "net/http" 4 | 5 | type SingleFileServer struct { 6 | path string 7 | } 8 | 9 | func NewSingleFileServer(path string) *SingleFileServer { 10 | return &SingleFileServer{path} 11 | } 12 | 13 | func (s *SingleFileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 14 | http.ServeFile(w, r, s.path) 15 | } 16 | -------------------------------------------------------------------------------- /ui/assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "assets", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "None", 11 | "devDependencies": { 12 | "gulp-plumber": "^1.0.1", 13 | "gulp-sass": "^2.3.2" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /http_debug.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type DebugHandlerWrapper struct { 8 | handler http.Handler 9 | } 10 | 11 | func NewDebugHandlerWrapper(handler http.Handler) *DebugHandlerWrapper { 12 | return &DebugHandlerWrapper{handler} 13 | } 14 | 15 | func (s *DebugHandlerWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) { 16 | debug.Printf("%v %v", r.Method, r.URL.Path) 17 | s.handler.ServeHTTP(w, r) 18 | } 19 | -------------------------------------------------------------------------------- /debug.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | ) 7 | 8 | type Debugger struct { 9 | debug bool 10 | } 11 | 12 | var enableDebugging = true 13 | 14 | func init() { 15 | flag.BoolVar(&enableDebugging, "debug", true, "debug output") 16 | debug = &Debugger{enableDebugging} 17 | } 18 | 19 | var debug *Debugger = nil 20 | 21 | func (d Debugger) Printf(format string, args ...interface{}) { 22 | if enableDebugging { 23 | log.Printf("DEBUG: "+format, args...) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Golang HLS Streamer 2 | =================== 3 | 4 | Simple server that exposes a directory for video streaming via HTTP Live Streaming (HLS). 5 | Uses ffmpeg for transcoding. 6 | 7 | This project is cobbled together from all kinds of code I has lying around so it's pretty crappy all around. 8 | 9 | Running it 10 | ---------- 11 | 12 | - Place ffmpeg and ffprobe binaries in "tools" dir 13 | - Run go run *.go in project root (e.g. go run *.go ~/Documents/) 14 | - Access http://localhost:8080/ui/ 15 | 16 | License 17 | ------- 18 | See LICENSE.txt 19 | 20 | -------------------------------------------------------------------------------- /http_frame.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os/exec" 7 | "path" 8 | ) 9 | 10 | type FrameHandler struct { 11 | root string 12 | } 13 | 14 | func NewFrameHandler(root string) *FrameHandler { 15 | return &FrameHandler{root} 16 | } 17 | 18 | func (s *FrameHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 19 | path := path.Join(s.root, r.URL.Path) 20 | cmd := exec.Command("tools/ffmpeg", "-loglevel", "error", "-ss", "00:00:30", "-i", path, "-vf", "scale=320:-1", "-frames:v", "1", "-f", "image2", "-") 21 | if err := ServeCommand(cmd, w); err != nil { 22 | log.Printf("Error serving screenshot: %v", err) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ui/assets/gulpfile.js: -------------------------------------------------------------------------------- 1 | 2 | var gulp = require('gulp'); 3 | var sass = require('gulp-sass'); 4 | var babel = require('gulp-babel'); 5 | var plumber = require('gulp-plumber'); 6 | 7 | gulp.task('sass', function () { 8 | gulp.src('./sass/app.scss') 9 | .pipe(plumber()) 10 | .pipe(sass()) 11 | .pipe(gulp.dest('./css/')); 12 | }); 13 | 14 | gulp.task('babel', function () { 15 | gulp.src('./jsx/*.jsx') 16 | .pipe(plumber()) 17 | .pipe(babel()) 18 | .pipe(gulp.dest('./js/')); 19 | }); 20 | 21 | gulp.task('watch', ['default'], function() { 22 | gulp.watch('jsx/**/*.jsx', ['babel']); 23 | gulp.watch('sass/**/*.scss', ['sass']); 24 | 25 | }); 26 | 27 | gulp.task('default',['sass','babel']); -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Videos 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /http_util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "log" 7 | "net/http" 8 | "os/exec" 9 | "syscall" 10 | ) 11 | 12 | func ServeCommand(cmd *exec.Cmd, w io.Writer) error { 13 | stdout, err := cmd.StdoutPipe() 14 | defer stdout.Close() 15 | if err != nil { 16 | log.Printf("Error opening stdout of command: %v", err) 17 | return err 18 | } 19 | err = cmd.Start() 20 | if err != nil { 21 | log.Printf("Error starting command: %v", err) 22 | return err 23 | } 24 | _, err = io.Copy(w, stdout) 25 | if err != nil { 26 | log.Printf("Error copying data to client: %v", err) 27 | // Ask the process to exit 28 | cmd.Process.Signal(syscall.SIGKILL) 29 | cmd.Process.Wait() 30 | return err 31 | } 32 | cmd.Wait() 33 | return nil 34 | } 35 | 36 | func ServeJson(status int, data interface{}, w http.ResponseWriter) { 37 | js, err := json.Marshal(data) 38 | if err != nil { 39 | http.Error(w, err.Error(), http.StatusInternalServerError) 40 | return 41 | } 42 | w.Header().Set("Content-Type", "application/json") 43 | w.Write(js) 44 | } 45 | -------------------------------------------------------------------------------- /http_stream.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os/exec" 7 | "path" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | type StreamHandler struct { 13 | root string 14 | } 15 | 16 | func NewStreamHandler(root string) *StreamHandler { 17 | return &StreamHandler{root} 18 | } 19 | 20 | func (s *StreamHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 21 | filePath := path.Join(s.root, r.URL.Path[0:strings.LastIndex(r.URL.Path, "/")]) 22 | idx, _ := strconv.ParseInt(r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:strings.LastIndex(r.URL.Path, ".")], 0, 64) 23 | startTime := idx * hlsSegmentLength 24 | debug.Printf("Streaming second %v of %v", startTime, filePath) 25 | 26 | w.Header()["Access-Control-Allow-Origin"] = []string{"*"} 27 | 28 | cmd := exec.Command("tools/ffmpeg", "-ss", fmt.Sprintf("%v", startTime), "-t", "5", "-i", filePath, "-vcodec", "libx264", "-strict", "experimental", "-acodec", "aac", "-pix_fmt", "yuv420p", "-r", "25", "-profile:v", "baseline", "-b:v", "2000k", "-maxrate", "2500k", "-f", "mpegts", "-") 29 | ServeCommand(cmd, w) 30 | } 31 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "net/http" 6 | "path" 7 | ) 8 | 9 | func main() { 10 | flag.Parse() 11 | uiDirectory := path.Join(".", "ui") 12 | indexHtml := path.Join(uiDirectory, "index.html") 13 | contentDir := path.Join(".", "videos") 14 | if flag.NArg() > 0 { 15 | contentDir = flag.Arg(0) 16 | } 17 | 18 | http.Handle("/", http.RedirectHandler("/ui/",302)) 19 | http.Handle("/ui/assets/", http.StripPrefix("/ui/", http.FileServer(http.Dir(uiDirectory)))) 20 | http.Handle("/ui/", NewDebugHandlerWrapper(http.StripPrefix("/ui/", NewSingleFileServer(indexHtml)))) 21 | http.Handle("/list/", NewDebugHandlerWrapper(http.StripPrefix("/list/", NewListHandler(contentDir)))) 22 | http.Handle("/frame/", NewDebugHandlerWrapper(http.StripPrefix("/frame/", NewFrameHandler(contentDir)))) 23 | http.Handle("/playlist/", NewDebugHandlerWrapper(http.StripPrefix("/playlist/", NewPlaylistHandler(contentDir)))) 24 | http.Handle("/segments/", NewDebugHandlerWrapper(http.StripPrefix("/segments/", NewStreamHandler(contentDir)))) 25 | http.ListenAndServe(":8080", nil) 26 | 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | BSD License 2 | 3 | Copyright (c) 2015, Sebastian Himberger 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, 7 | are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name Sebastian Himberger nor the names of its contributors may be used to 17 | endorse or promote products derived from this software without specific 18 | prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 24 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 26 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 27 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 29 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /http_playlists.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "path" 8 | ) 9 | 10 | // UrlEncoded encodes a string like Javascript's encodeURIComponent() 11 | func UrlEncoded(str string) (string, error) { 12 | u, err := url.Parse(str) 13 | if err != nil { 14 | return "", err 15 | } 16 | return u.String(), nil 17 | } 18 | 19 | const hlsSegmentLength = 5.0 // 5 Seconds 20 | 21 | type PlaylistHandler struct { 22 | root string 23 | } 24 | 25 | func NewPlaylistHandler(root string) *PlaylistHandler { 26 | return &PlaylistHandler{root} 27 | } 28 | 29 | func (s *PlaylistHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 30 | filePath := path.Join(s.root, r.URL.Path) 31 | vinfo, err := GetVideoInformation(filePath) 32 | if err != nil { 33 | http.Error(w, err.Error(), http.StatusInternalServerError) 34 | return 35 | } 36 | duration := vinfo.Duration 37 | baseurl := fmt.Sprintf("http://%v", r.Host) 38 | 39 | id, err := UrlEncoded(r.URL.Path) 40 | if err != nil { 41 | http.Error(w, err.Error(), http.StatusInternalServerError) 42 | return 43 | } 44 | 45 | w.Header()["Content-Type"] = []string{"application/vnd.apple.mpegurl"} 46 | w.Header()["Access-Control-Allow-Origin"] = []string{"*"} 47 | 48 | fmt.Fprint(w, "#EXTM3U\n") 49 | fmt.Fprint(w, "#EXT-X-VERSION:3\n") 50 | fmt.Fprint(w, "#EXT-X-MEDIA-SEQUENCE:0\n") 51 | fmt.Fprint(w, "#EXT-X-ALLOW-CACHE:YES\n") 52 | fmt.Fprint(w, "#EXT-X-TARGETDURATION:5\n") 53 | fmt.Fprint(w, "#EXT-X-PLAYLIST-TYPE:VOD\n") 54 | 55 | leftover := duration 56 | segmentIndex := 0 57 | 58 | for leftover > 0 { 59 | if leftover > hlsSegmentLength { 60 | fmt.Fprintf(w, "#EXTINF: %f,\n", hlsSegmentLength) 61 | } else { 62 | fmt.Fprintf(w, "#EXTINF: %f,\n", leftover) 63 | } 64 | fmt.Fprintf(w, baseurl+"/segments/%v/%v.ts\n", id, segmentIndex) 65 | segmentIndex++ 66 | leftover = leftover - hlsSegmentLength 67 | } 68 | fmt.Fprint(w, "#EXT-X-ENDLIST\n") 69 | } 70 | -------------------------------------------------------------------------------- /http_list.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | "path" 9 | "strings" 10 | ) 11 | 12 | type ListResponseVideo struct { 13 | Name string `json:"name"` 14 | Path string `json:"path"` 15 | Info *VideoInfo `json:"info"` 16 | } 17 | 18 | type ListResponseFolder struct { 19 | Name string `json:"name"` 20 | Path string `json:"path"` 21 | } 22 | 23 | type ListResponse struct { 24 | Error error `json:"error"` 25 | Folders []*ListResponseFolder `json:"folders"` 26 | Videos []*ListResponseVideo `json:"videos"` 27 | } 28 | 29 | type ListHandler struct { 30 | path string 31 | } 32 | 33 | func NewListHandler(path string) *ListHandler { 34 | return &ListHandler{path} 35 | } 36 | 37 | func (s *ListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 38 | videos := make([]*ListResponseVideo, 0) 39 | folders := make([]*ListResponseFolder, 0) 40 | response := &ListResponse{nil, folders, videos} 41 | files, rerr := ioutil.ReadDir(path.Join(s.path, r.URL.Path)) 42 | if rerr != nil { 43 | response.Error = fmt.Errorf("Error reading path: %v", r.URL.Path) 44 | ServeJson(500, response, w) 45 | return 46 | } 47 | for _, f := range files { 48 | filePath := path.Join(s.path, r.URL.Path, f.Name()) 49 | if strings.HasPrefix(f.Name(), ".") || strings.HasPrefix(f.Name(), "$") { 50 | continue 51 | } 52 | if FilenameLooksLikeVideo(filePath) { 53 | vinfo, err := GetVideoInformation(filePath) 54 | if err != nil { 55 | log.Printf("Could not read video information of %v: %v", filePath, err) 56 | } 57 | video := &ListResponseVideo{f.Name(), path.Join(r.URL.Path, f.Name()), vinfo} 58 | videos = append(videos, video) 59 | } 60 | if f.IsDir() { 61 | folder := &ListResponseFolder{f.Name(), path.Join(r.URL.Path, f.Name())} 62 | folders = append(folders, folder) 63 | } 64 | } 65 | response.Videos = videos 66 | response.Folders = folders 67 | ServeJson(200, response, w) 68 | } 69 | -------------------------------------------------------------------------------- /video_info.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os/exec" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | var videoSuffixes = []string{".mp4", ".avi", ".mkv", ".flv", ".wmv", ".mov", ".mpg"} 12 | 13 | var videoInfos = make(map[string]*VideoInfo) 14 | 15 | type VideoInfo struct { 16 | Duration float64 `json:"duration"` 17 | } 18 | 19 | func FilenameLooksLikeVideo(name string) bool { 20 | for _, suffix := range videoSuffixes { 21 | if strings.HasSuffix(name, suffix) { 22 | return true 23 | } 24 | } 25 | return false 26 | } 27 | 28 | func GetRawFFMPEGInfo(path string) ([]byte, error) { 29 | debug.Printf("Executing ffprobe for %v", path) 30 | cmd := exec.Command("./tools/ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", ""+path+"") 31 | data, err := cmd.Output() 32 | if err != nil { 33 | return nil, fmt.Errorf("Error executing ffprobe for file '%v':", path, err) 34 | } 35 | return data, nil 36 | } 37 | 38 | func GetFFMPEGJson(path string) (map[string]interface{}, error) { 39 | data, cmderr := GetRawFFMPEGInfo(path) 40 | if cmderr != nil { 41 | return nil, cmderr 42 | } 43 | var info map[string]interface{} 44 | err := json.Unmarshal(data, &info) 45 | if err != nil { 46 | return nil, fmt.Errorf("Error unmarshalling JSON from ffprobe output for file '%v':", path, err) 47 | } 48 | return info, nil 49 | } 50 | 51 | func GetVideoInformation(path string) (*VideoInfo, error) { 52 | if data, ok := videoInfos[path]; ok { 53 | return data, nil 54 | } 55 | info, jsonerr := GetFFMPEGJson(path) 56 | if jsonerr != nil { 57 | return nil, jsonerr 58 | } 59 | debug.Printf("ffprobe for %v returned", path, info) 60 | if _, ok := info["format"]; !ok { 61 | return nil, fmt.Errorf("ffprobe data for '%v' does not contain format info", path) 62 | } 63 | format := info["format"].(map[string]interface{}) 64 | if _, ok := format["duration"]; !ok { 65 | return nil, fmt.Errorf("ffprobe format data for '%v' does not contain duration", path) 66 | } 67 | duration, perr := strconv.ParseFloat(format["duration"].(string), 64) 68 | if perr != nil { 69 | return nil, fmt.Errorf("Could not parse duration (%v) of '%v': ", format["duration"].(string), path, perr) 70 | } 71 | var vi = &VideoInfo{duration} 72 | videoInfos[path] = vi 73 | return vi, nil 74 | } 75 | -------------------------------------------------------------------------------- /ui/assets/css/app.css: -------------------------------------------------------------------------------- 1 | body, html { 2 | margin: 0; 3 | padding: 0; } 4 | 5 | .loader, .empty-message { 6 | text-align: center; 7 | padding: 2rem; 8 | font-size: 2.5rem; } 9 | 10 | .back > span { 11 | position: absolute; 12 | top: 1rem; 13 | left: 1rem; 14 | color: white; 15 | font-size: 30px; 16 | border: 2px solid white; 17 | border-radius: 1000px; 18 | text-align: center; 19 | width: 75px; 20 | height: 75px; 21 | line-height: 70px; 22 | display: block; } 23 | 24 | .back:hover > span { 25 | background: rgba(200, 200, 200, 0.75); } 26 | 27 | .list-items .list-item { 28 | color: #333; 29 | overflow: hidden; 30 | position: relative; 31 | display: flex; 32 | border-bottom: 1px solid #000; } 33 | .list-items .list-item img { 34 | width: 100%; } 35 | .list-items .list-item .left, .list-items .list-item .right { 36 | display: flex; 37 | float: left; 38 | padding: 0.75rem; } 39 | .list-items .list-item .left { 40 | width: 30%; 41 | max-width: 250px; 42 | text-align: center; 43 | background: #333; 44 | color: white; 45 | font-size: 3rem; } 46 | .list-items .list-item .right { 47 | display: flex; 48 | flex-direction: column; 49 | justify-content: center; 50 | width: 70%; 51 | font-size: 2rem; } 52 | .list-items .list-item .frame { 53 | background-color: black; 54 | position: relative; 55 | width: 100%; 56 | padding-top: 56%; 57 | background-size: cover; } 58 | .list-items .list-item .frame > .inner { 59 | display: flex; 60 | flex-direction: column; 61 | justify-content: center; 62 | position: absolute; 63 | top: 0; 64 | bottom: 0; 65 | left: 0; 66 | right: 0; } 67 | .list-items .list-item.video .frame > .inner { 68 | display: none; } 69 | 70 | .list-items a:hover { 71 | text-decoration: none; } 72 | .list-items a:hover .list-item { 73 | background: #eeeeee; } 74 | .list-items a:hover .list-item .left { 75 | background: #555; } 76 | .list-items a:hover .list-item.video .frame > .inner { 77 | background: rgba(100, 100, 100, 0.3); 78 | display: flex; } 79 | 80 | .player { 81 | min-height: 100vh; 82 | background: black; 83 | display: flex; 84 | flex-direction: column; 85 | justify-content: center; 86 | overflow: hidden; } 87 | .player video { 88 | max-height: 100vh; } 89 | -------------------------------------------------------------------------------- /ui/assets/sass/app.scss: -------------------------------------------------------------------------------- 1 | 2 | body, html { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | .loader, .empty-message { 8 | text-align: center; 9 | padding: 2rem; 10 | font-size: 2.5rem; 11 | } 12 | 13 | .back > span { 14 | position: absolute; 15 | top: 1rem; 16 | left: 1rem; 17 | color: white; 18 | font-size: 30px; 19 | border: 2px solid white; 20 | border-radius: 1000px; 21 | text-align: center; 22 | width: 75px; 23 | height: 75px; 24 | line-height: 70px; 25 | display: block; 26 | } 27 | 28 | .back:hover > span { 29 | background: rgba(200,200,200,0.75) 30 | } 31 | 32 | 33 | .list-items { 34 | 35 | .list-item { 36 | color: #333; 37 | overflow: hidden; 38 | position: relative; 39 | display: flex; 40 | border-bottom: 1px solid #000; 41 | 42 | img { 43 | width: 100%; 44 | } 45 | 46 | .left, .right { 47 | display: flex; 48 | float: left; 49 | padding: 0.75rem; 50 | } 51 | 52 | .left { 53 | width: 30%; 54 | max-width: 250px; 55 | text-align: center; 56 | background: #333; 57 | color: white; 58 | font-size: 3rem; 59 | } 60 | .right { 61 | display: flex; 62 | flex-direction: column; 63 | justify-content: center; 64 | width: 70%; 65 | font-size: 2rem; 66 | 67 | } 68 | 69 | .frame { 70 | background-color: black; 71 | position: relative; 72 | width: 100%; 73 | padding-top: 56%; 74 | background-size: cover; 75 | } 76 | 77 | .frame > .inner { 78 | display: flex; 79 | flex-direction: column; 80 | justify-content: center; 81 | position: absolute; 82 | top: 0; 83 | bottom: 0; 84 | left: 0; 85 | right: 0; 86 | } 87 | 88 | &.video .frame > .inner { 89 | display: none; 90 | } 91 | 92 | } 93 | 94 | a:hover { 95 | text-decoration: none; 96 | 97 | .list-item { 98 | background: #eeeeee; 99 | 100 | .left { 101 | background: #555; 102 | } 103 | 104 | &.video .frame > .inner { 105 | background: rgba(100,100,100,0.3); 106 | display: flex; 107 | } 108 | 109 | } 110 | 111 | 112 | } 113 | 114 | } 115 | 116 | .player { 117 | min-height: 100vh; 118 | background: black; 119 | display: flex; 120 | flex-direction: column; 121 | justify-content: center; 122 | overflow: hidden; 123 | 124 | video { 125 | max-height: 100vh; 126 | 127 | } 128 | 129 | } -------------------------------------------------------------------------------- /ui/assets/img/loader.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/assets/jsx/app.jsx: -------------------------------------------------------------------------------- 1 | 2 | // Include RactRouter Module 3 | var Router = ReactRouter.create(); 4 | var Route = ReactRouter.Route; 5 | var RouteHandler = ReactRouter.RouteHandler; 6 | var DefaultRoute = ReactRouter.DefaultRoute; 7 | var Link = ReactRouter.Link; 8 | 9 | // Application Frame 10 | var App = React.createClass({ 11 | render () { 12 | return ( 13 | 14 | ) 15 | } 16 | }); 17 | 18 | var Player = React.createClass({ 19 | 20 | // HLS.js doesn't seem to work somehow' 21 | /* 22 | componentDidMount() { 23 | if (Hls.isSupported()) { 24 | let video = this._video.getDOMNode(); 25 | this.hls = new Hls({ 26 | debug: true, 27 | fragLoadingTimeOut: 60000, 28 | 29 | }); 30 | let hls = this.hls; 31 | let props = this.props; 32 | hls.attachMedia(video); 33 | hls.on(Hls.Events.ERROR, function (event, data) { 34 | console.log(data); 35 | }) 36 | hls.on(Hls.Events.MEDIA_ATTACHED, function () { 37 | console.log("video and hls.js are now bound together !"); 38 | hls.loadSource("/playlist/" + props.params.splat); 39 | hls.on(Hls.Events.MANIFEST_PARSED, function (event, data) { 40 | console.log(data) 41 | console.log("manifest loaded, found " + data.levels.length + " quality level"); 42 | video.play(); 43 | }); 44 | }); 45 | } 46 | }, 47 | 48 | componentWillUnmount() { 49 | this.hls.detachMedia() 50 | }, 51 | */ 52 | 53 | goBack(e) { 54 | e.preventDefault(); 55 | window.history.back(); 56 | }, 57 | 58 | render() { 59 | return ( 60 |
61 |
62 | 67 |
68 | 69 | 71 | 72 |
73 | ) 74 | } 75 | }) 76 | 77 | var Folder = React.createClass({ 78 | render() { 79 | return ( 80 | 81 |
82 |
83 |
84 |
85 | 86 |
87 |
88 |
89 |
90 | {this.props.name} 91 |
92 |
93 | 94 | ) 95 | } 96 | }) 97 | 98 | var Loader = React.createClass({ 99 | render() { 100 | return ( 101 |
102 | 103 |
104 | ) 105 | } 106 | }) 107 | 108 | var EmptyMessage = React.createClass({ 109 | render() { 110 | return ( 111 |
112 |

No folders or videos found in folder :-(

113 |
114 | ) 115 | } 116 | }) 117 | 118 | 119 | var Video = React.createClass({ 120 | render() { 121 | return ( 122 | 123 |
124 |
125 |
126 |
127 | 128 |
129 |
130 |
131 |
132 | {this.props.name} 133 |
134 |
135 | 136 | ) 137 | } 138 | }) 139 | 140 | var List = React.createClass({ 141 | 142 | getInitialState() { 143 | return { 144 | 'videos': null, 145 | 'folders': null 146 | } 147 | }, 148 | 149 | fetchData(path) { 150 | this.setState({ 151 | 'folders': null, 152 | 'videos': null 153 | }) 154 | $.get('/list/' + path,(data) => { 155 | this.setState({ 156 | 'folders': data.folders, 157 | 'videos': data.videos 158 | }) 159 | }); 160 | }, 161 | 162 | componentDidMount() { 163 | var path = this.props.params.splat || ""; 164 | this.fetchData(path) 165 | }, 166 | 167 | componentWillReceiveProps(nextProps) { 168 | var path = nextProps.params.splat || ""; 169 | this.fetchData(path) 170 | }, 171 | 172 | render () { 173 | let loader = (!this.state.folders) ? : null; 174 | let folders = [] 175 | let videos = [] 176 | if (this.state.folders) { 177 | folders = this.state.folders.map((folder) => ) 178 | videos = this.state.videos.map((video) =>