├── .gitignore ├── README.md ├── api ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── README.md ├── api │ ├── .gitkeep │ ├── auth-ably │ │ ├── config │ │ │ └── routes.json │ │ └── controllers │ │ │ └── auth-ably.js │ └── videos │ │ ├── config │ │ └── routes.json │ │ ├── controllers │ │ └── videos.js │ │ ├── models │ │ ├── videos.js │ │ └── videos.settings.json │ │ └── services │ │ └── videos.js ├── config │ ├── database.js │ ├── env │ │ └── production │ │ │ ├── database.js │ │ │ ├── plugins.js │ │ │ └── server.js │ ├── functions │ │ ├── bootstrap.js │ │ ├── cron.js │ │ └── responses │ │ │ └── 404.js │ └── server.js ├── extensions │ ├── .gitkeep │ └── users-permissions │ │ └── config │ │ └── jwt.js ├── package-lock.json ├── package.json ├── public │ ├── robots.txt │ └── uploads │ │ └── .gitkeep └── yarn.lock └── watch-party ├── .editorconfig ├── .gitignore ├── README.md ├── assets ├── README.md ├── ably-logo.png ├── grey-pattern.png ├── jamstack-logo.png ├── netlify-logo.png ├── strapi-logo.png ├── vid-thumbnail.png └── vid.mov ├── components ├── Comments │ ├── CommentsList.vue │ ├── SingleAvatar.vue │ └── SingleComment.vue ├── Logo.vue ├── ProjectReferences.vue ├── ProjectTitle.vue ├── README.md └── Video │ ├── VideoHeader.vue │ └── VideoPlayer.vue ├── layouts ├── README.md └── default.vue ├── middleware └── README.md ├── nuxt.config.js ├── package-lock.json ├── package.json ├── pages ├── README.md ├── gallery.vue ├── index.vue └── room.vue ├── plugins ├── README.md └── nuxt-video-player-plugin.js ├── static └── README.md ├── store ├── README.md └── index.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Synchronized video streaming with Jamstack 2 | 3 | This project demonstrates realtime messaging in Jamstack apps. Dubbed as a 'live watch party', this app allows a host to create a private watch party room, invite friends to it and watch videos together. They can also share live comments and see who's participating (online) in the party. 4 | 5 | You can try it yourself at https://jamstack-watch-party.ably.dev/. 6 | 7 | 8 | Watch party homepage 9 | 10 | 11 | The host is able to choose a video from the available library. 12 | 13 | Watch party video library 14 | 15 | The host also has full control of the video playback for all participants, including play, pause and seek events. 16 | 17 | Screenshot 2021-05-07 at 20 01 47 18 | 19 | 20 | ### How to run this locally 21 | 22 | 23 | Start your Strapi Server 24 | 25 | ``` 26 | $ cd api 27 | $ npm install 28 | $ npm run develop 29 | ``` 30 | 31 | Before you can get things working, head to http://localhost:1337/admin, create a new user and log into your Admin to add videos! Make sure you go to your **User Permissions** > **Roles** > **Public Role** > click the _find_ and _find_ one checkboxes under videos as well as the _auth_ checkbox under ably-auth. 32 | 33 | Start your Nuxt App 34 | ``` 35 | $ cd watch-party 36 | $ npm install 37 | $ npm run dev 38 | ``` 39 | 40 | Go to the browser and open http://localhost:3000/ and follow the flow. 41 | 42 | 43 | ### The tech stack 44 | 45 | We've used the technologies that work with the [Jamstack architecture](https://jamstack.org/): 46 | 47 | Frameworks and Libraries 48 | 1. [Nuxt.js](https://nuxtjs.org/) - A Vue framework capable of generating server side rendered (SSR) static sites. 49 | 2. [Strapi](https://strapi.io/) - An open-source headless CMS. 50 | 3. [Ably](https://ably.com/) - A scalable pub/sub messaging platform. 51 | 4. [Netlify](https://www.netlify.com/) - A serverless platform to build, deploy, and collaborate on web apps. 52 | 53 | ### App architecture 54 | 55 | ![Realtime watch party app architecture](https://user-images.githubusercontent.com/5900152/118970089-76396e00-b98b-11eb-954d-631fe561c318.png) 56 | 57 | ### Resources 58 | 59 | TBD 60 | -------------------------------------------------------------------------------- /api/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [{package.json,*.yml}] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /api/.eslintignore: -------------------------------------------------------------------------------- 1 | .cache 2 | build 3 | **/node_modules/** 4 | -------------------------------------------------------------------------------- /api/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "eslint:recommended", 4 | "env": { 5 | "commonjs": true, 6 | "es6": true, 7 | "node": true, 8 | "browser": false 9 | }, 10 | "parserOptions": { 11 | "ecmaFeatures": { 12 | "experimentalObjectRestSpread": true, 13 | "jsx": false 14 | }, 15 | "sourceType": "module" 16 | }, 17 | "globals": { 18 | "strapi": true 19 | }, 20 | "rules": { 21 | "indent": ["error", 2, { "SwitchCase": 1 }], 22 | "linebreak-style": ["error", "unix"], 23 | "no-console": 0, 24 | "quotes": ["error", "single"], 25 | "semi": ["error", "always"] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | ############################ 2 | # OS X 3 | ############################ 4 | 5 | .DS_Store 6 | .AppleDouble 7 | .LSOverride 8 | Icon 9 | .Spotlight-V100 10 | .Trashes 11 | ._* 12 | 13 | 14 | ############################ 15 | # Linux 16 | ############################ 17 | 18 | *~ 19 | 20 | 21 | ############################ 22 | # Windows 23 | ############################ 24 | 25 | Thumbs.db 26 | ehthumbs.db 27 | Desktop.ini 28 | $RECYCLE.BIN/ 29 | *.cab 30 | *.msi 31 | *.msm 32 | *.msp 33 | 34 | 35 | ############################ 36 | # Packages 37 | ############################ 38 | 39 | *.7z 40 | *.csv 41 | *.dat 42 | *.dmg 43 | *.gz 44 | *.iso 45 | *.jar 46 | *.rar 47 | *.tar 48 | *.zip 49 | *.com 50 | *.class 51 | *.dll 52 | *.exe 53 | *.o 54 | *.seed 55 | *.so 56 | *.swo 57 | *.swp 58 | *.swn 59 | *.swm 60 | *.out 61 | *.pid 62 | 63 | 64 | ############################ 65 | # Logs and databases 66 | ############################ 67 | 68 | .tmp 69 | *.log 70 | *.sql 71 | *.sqlite 72 | *.sqlite3 73 | 74 | 75 | ############################ 76 | # Misc. 77 | ############################ 78 | 79 | *# 80 | ssl 81 | .idea 82 | nbproject 83 | public/uploads/* 84 | !public/uploads/.gitkeep 85 | 86 | ############################ 87 | # Node.js 88 | ############################ 89 | 90 | lib-cov 91 | lcov.info 92 | pids 93 | logs 94 | results 95 | node_modules 96 | .node_history 97 | 98 | ############################ 99 | # Tests 100 | ############################ 101 | 102 | testApp 103 | coverage 104 | 105 | ############################ 106 | # Strapi 107 | ############################ 108 | 109 | .env 110 | license.txt 111 | exports 112 | *.cache 113 | build 114 | .strapi-updater.json 115 | package-lock.json -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # Strapi application 2 | 3 | A quick description of your strapi application 4 | -------------------------------------------------------------------------------- /api/api/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/jamstack-sync-stream-video/691a666248cb48d07b0d4466bbbd19f0d242dfd9/api/api/.gitkeep -------------------------------------------------------------------------------- /api/api/auth-ably/config/routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "routes": [ 3 | { 4 | "method": "GET", 5 | "path": "/auth-ably", 6 | "handler": "auth-ably.auth", 7 | "config": { 8 | "policies": [] 9 | } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /api/api/auth-ably/controllers/auth-ably.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Ably = require('ably/promises'); 4 | const ABLY_API_KEY = process.env.ABLY_API_KEY; 5 | 6 | const realtime = Ably.Realtime({ 7 | key: ABLY_API_KEY, 8 | echoMessages: false 9 | }); 10 | 11 | 12 | module.exports = { 13 | async auth(ctx) { 14 | const clientId = 'id-' + Math.random().toString(36).substr(2, 16) 15 | const tokenParams = { clientId }; 16 | try { 17 | const ablyThing = await realtime.auth.createTokenRequest(tokenParams); 18 | return ablyThing 19 | } 20 | catch (err) { 21 | return ctx.badRequest("Daas not good!!") 22 | } 23 | } 24 | }; -------------------------------------------------------------------------------- /api/api/videos/config/routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "routes": [ 3 | { 4 | "method": "GET", 5 | "path": "/videos", 6 | "handler": "videos.find", 7 | "config": { 8 | "policies": [] 9 | } 10 | }, 11 | { 12 | "method": "GET", 13 | "path": "/videos/count", 14 | "handler": "videos.count", 15 | "config": { 16 | "policies": [] 17 | } 18 | }, 19 | { 20 | "method": "GET", 21 | "path": "/videos/:id", 22 | "handler": "videos.findOne", 23 | "config": { 24 | "policies": [] 25 | } 26 | }, 27 | { 28 | "method": "POST", 29 | "path": "/videos", 30 | "handler": "videos.create", 31 | "config": { 32 | "policies": [] 33 | } 34 | }, 35 | { 36 | "method": "PUT", 37 | "path": "/videos/:id", 38 | "handler": "videos.update", 39 | "config": { 40 | "policies": [] 41 | } 42 | }, 43 | { 44 | "method": "DELETE", 45 | "path": "/videos/:id", 46 | "handler": "videos.delete", 47 | "config": { 48 | "policies": [] 49 | } 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /api/api/videos/controllers/videos.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Read the documentation (https://strapi.io/documentation/developer-docs/latest/development/backend-customization.html#core-controllers) 5 | * to customize this controller 6 | */ 7 | 8 | module.exports = {}; 9 | -------------------------------------------------------------------------------- /api/api/videos/models/videos.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Read the documentation (https://strapi.io/documentation/developer-docs/latest/development/backend-customization.html#lifecycle-hooks) 5 | * to customize this model 6 | */ 7 | 8 | module.exports = {}; 9 | -------------------------------------------------------------------------------- /api/api/videos/models/videos.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "collectionType", 3 | "collectionName": "videos", 4 | "info": { 5 | "name": "videos" 6 | }, 7 | "options": { 8 | "increments": true, 9 | "timestamps": true, 10 | "draftAndPublish": true 11 | }, 12 | "attributes": { 13 | "title": { 14 | "type": "string" 15 | }, 16 | "description": { 17 | "type": "text" 18 | }, 19 | "video": { 20 | "model": "file", 21 | "via": "related", 22 | "allowedTypes": [ 23 | "images", 24 | "files", 25 | "videos" 26 | ], 27 | "plugin": "upload", 28 | "required": false 29 | }, 30 | "thumbnail": { 31 | "model": "file", 32 | "via": "related", 33 | "allowedTypes": [ 34 | "images", 35 | "files", 36 | "videos" 37 | ], 38 | "plugin": "upload", 39 | "required": false 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /api/api/videos/services/videos.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Read the documentation (https://strapi.io/documentation/developer-docs/latest/development/backend-customization.html#core-services) 5 | * to customize this service 6 | */ 7 | 8 | module.exports = {}; 9 | -------------------------------------------------------------------------------- /api/config/database.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ env }) => ({ 2 | defaultConnection: 'default', 3 | connections: { 4 | default: { 5 | connector: 'bookshelf', 6 | settings: { 7 | client: 'sqlite', 8 | filename: env('DATABASE_FILENAME', '.tmp/data.db'), 9 | }, 10 | options: { 11 | useNullAsDefault: true, 12 | }, 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /api/config/env/production/database.js: -------------------------------------------------------------------------------- 1 | const parse = require('pg-connection-string').parse; 2 | const config = parse(process.env.DATABASE_URL); 3 | 4 | module.exports = ({ env }) => ({ 5 | defaultConnection: 'default', 6 | connections: { 7 | default: { 8 | connector: 'bookshelf', 9 | settings: { 10 | client: 'postgres', 11 | host: config.host, 12 | port: config.port, 13 | database: config.database, 14 | username: config.user, 15 | password: config.password, 16 | ssl: { 17 | rejectUnauthorized: false, 18 | }, 19 | }, 20 | options: { 21 | ssl: true, 22 | }, 23 | }, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /api/config/env/production/plugins.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ env }) => ({ 2 | upload: { 3 | provider: 'cloudinary', 4 | providerOptions: { 5 | cloud_name: env('CLOUDINARY_NAME'), 6 | api_key: env('CLOUDINARY_KEY'), 7 | api_secret: env('CLOUDINARY_SECRET'), 8 | }, 9 | actionOptions: { 10 | upload: {}, 11 | delete: {}, 12 | }, 13 | }, 14 | }); -------------------------------------------------------------------------------- /api/config/env/production/server.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ env }) => ({ 2 | url: env('MY_HEROKU_URL'), 3 | }); -------------------------------------------------------------------------------- /api/config/functions/bootstrap.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * An asynchronous bootstrap function that runs before 5 | * your application gets started. 6 | * 7 | * This gives you an opportunity to set up your data model, 8 | * run jobs, or perform some special logic. 9 | * 10 | * See more details here: https://strapi.io/documentation/developer-docs/latest/setup-deployment-guides/configurations.html#bootstrap 11 | */ 12 | 13 | module.exports = () => {}; 14 | -------------------------------------------------------------------------------- /api/config/functions/cron.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Cron config that gives you an opportunity 5 | * to run scheduled jobs. 6 | * 7 | * The cron format consists of: 8 | * [SECOND (optional)] [MINUTE] [HOUR] [DAY OF MONTH] [MONTH OF YEAR] [DAY OF WEEK] 9 | * 10 | * See more details here: https://strapi.io/documentation/developer-docs/latest/setup-deployment-guides/configurations.html#cron-tasks 11 | */ 12 | 13 | module.exports = { 14 | /** 15 | * Simple example. 16 | * Every monday at 1am. 17 | */ 18 | // '0 1 * * 1': () => { 19 | // 20 | // } 21 | }; 22 | -------------------------------------------------------------------------------- /api/config/functions/responses/404.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = async (/* ctx */) => { 4 | // return ctx.notFound('My custom message 404'); 5 | }; 6 | -------------------------------------------------------------------------------- /api/config/server.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ env }) => ({ 2 | host: env('HOST', '0.0.0.0'), 3 | port: env.int('PORT', 1337), 4 | admin: { 5 | auth: { 6 | secret: env('ADMIN_JWT_SECRET', '57ff35fc6f48eab35329d409e9a74715'), 7 | }, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /api/extensions/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/jamstack-sync-stream-video/691a666248cb48d07b0d4466bbbd19f0d242dfd9/api/extensions/.gitkeep -------------------------------------------------------------------------------- /api/extensions/users-permissions/config/jwt.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | jwtSecret: process.env.JWT_SECRET || 'a5727b63-d6df-48f4-92d4-df8d320145d3' 3 | }; -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "private": true, 4 | "version": "0.1.0", 5 | "description": "A Strapi application", 6 | "scripts": { 7 | "develop": "strapi develop", 8 | "start": "strapi start", 9 | "build": "strapi build", 10 | "strapi": "strapi" 11 | }, 12 | "devDependencies": {}, 13 | "dependencies": { 14 | "ably": "^1.2.9", 15 | "knex": "0.21.18", 16 | "pg": "^8.6.0", 17 | "pg-connection-string": "^2.5.0", 18 | "sqlite3": "5.0.0", 19 | "strapi": "3.5.4", 20 | "strapi-admin": "3.5.4", 21 | "strapi-connector-bookshelf": "3.5.4", 22 | "strapi-plugin-content-manager": "3.5.4", 23 | "strapi-plugin-content-type-builder": "3.5.4", 24 | "strapi-plugin-email": "3.5.4", 25 | "strapi-plugin-graphql": "3.5.4", 26 | "strapi-plugin-upload": "3.5.4", 27 | "strapi-plugin-users-permissions": "3.5.4", 28 | "strapi-provider-upload-cloudinary": "^3.6.2", 29 | "strapi-utils": "3.5.4" 30 | }, 31 | "author": { 32 | "name": "A Strapi developer" 33 | }, 34 | "strapi": { 35 | "uuid": "719d7288-d217-4e42-a77e-47aca3f61f19" 36 | }, 37 | "engines": { 38 | "node": ">=10.16.0 <=14.x.x", 39 | "npm": "^6.0.0" 40 | }, 41 | "license": "MIT" 42 | } 43 | -------------------------------------------------------------------------------- /api/public/robots.txt: -------------------------------------------------------------------------------- 1 | # To prevent search engines from seeing the site altogether, uncomment the next two lines: 2 | # User-Agent: * 3 | # Disallow: / 4 | -------------------------------------------------------------------------------- /api/public/uploads/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/jamstack-sync-stream-video/691a666248cb48d07b0d4466bbbd19f0d242dfd9/api/public/uploads/.gitkeep -------------------------------------------------------------------------------- /watch-party/.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /watch-party/.gitignore: -------------------------------------------------------------------------------- 1 | vetur.config.js 2 | tailwind.config.js 3 | keys.js 4 | 5 | # Created by .ignore support plugin (hsz.mobi) 6 | ### Node template 7 | # Logs 8 | /logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | 66 | # parcel-bundler cache (https://parceljs.org/) 67 | .cache 68 | 69 | # next.js build output 70 | .next 71 | 72 | # nuxt.js build output 73 | .nuxt 74 | 75 | # Nuxt generate 76 | dist 77 | 78 | # vuepress build output 79 | .vuepress/dist 80 | 81 | # Serverless directories 82 | .serverless 83 | 84 | # IDE / Editor 85 | .idea 86 | 87 | # Service worker 88 | sw.* 89 | 90 | # macOS 91 | .DS_Store 92 | 93 | # Vim swap files 94 | *.swp 95 | -------------------------------------------------------------------------------- /watch-party/README.md: -------------------------------------------------------------------------------- 1 | # watch-party 2 | 3 | ## Build Setup 4 | 5 | ```bash 6 | # install dependencies 7 | $ npm install 8 | 9 | # serve with hot reload at localhost:3000 10 | $ npm run dev 11 | 12 | # build for production and launch server 13 | $ npm run build 14 | $ npm run start 15 | 16 | # generate static project 17 | $ npm run generate 18 | ``` 19 | 20 | For detailed explanation on how things work, check out [Nuxt.js docs](https://nuxtjs.org). 21 | -------------------------------------------------------------------------------- /watch-party/assets/README.md: -------------------------------------------------------------------------------- 1 | # ASSETS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your un-compiled assets such as LESS, SASS, or JavaScript. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#webpacked). 8 | -------------------------------------------------------------------------------- /watch-party/assets/ably-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/jamstack-sync-stream-video/691a666248cb48d07b0d4466bbbd19f0d242dfd9/watch-party/assets/ably-logo.png -------------------------------------------------------------------------------- /watch-party/assets/grey-pattern.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/jamstack-sync-stream-video/691a666248cb48d07b0d4466bbbd19f0d242dfd9/watch-party/assets/grey-pattern.png -------------------------------------------------------------------------------- /watch-party/assets/jamstack-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/jamstack-sync-stream-video/691a666248cb48d07b0d4466bbbd19f0d242dfd9/watch-party/assets/jamstack-logo.png -------------------------------------------------------------------------------- /watch-party/assets/netlify-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/jamstack-sync-stream-video/691a666248cb48d07b0d4466bbbd19f0d242dfd9/watch-party/assets/netlify-logo.png -------------------------------------------------------------------------------- /watch-party/assets/strapi-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/jamstack-sync-stream-video/691a666248cb48d07b0d4466bbbd19f0d242dfd9/watch-party/assets/strapi-logo.png -------------------------------------------------------------------------------- /watch-party/assets/vid-thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/jamstack-sync-stream-video/691a666248cb48d07b0d4466bbbd19f0d242dfd9/watch-party/assets/vid-thumbnail.png -------------------------------------------------------------------------------- /watch-party/assets/vid.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ably-labs/jamstack-sync-stream-video/691a666248cb48d07b0d4466bbbd19f0d242dfd9/watch-party/assets/vid.mov -------------------------------------------------------------------------------- /watch-party/components/Comments/CommentsList.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 145 | 146 | 220 | -------------------------------------------------------------------------------- /watch-party/components/Comments/SingleAvatar.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 15 | 16 | 35 | -------------------------------------------------------------------------------- /watch-party/components/Comments/SingleComment.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 14 | 15 | 33 | -------------------------------------------------------------------------------- /watch-party/components/Logo.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 30 | -------------------------------------------------------------------------------- /watch-party/components/ProjectReferences.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 66 | -------------------------------------------------------------------------------- /watch-party/components/ProjectTitle.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 42 | -------------------------------------------------------------------------------- /watch-party/components/README.md: -------------------------------------------------------------------------------- 1 | # COMPONENTS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | The components directory contains your Vue.js Components. 6 | 7 | _Nuxt.js doesn't supercharge these components._ 8 | -------------------------------------------------------------------------------- /watch-party/components/Video/VideoHeader.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 23 | 24 | 75 | -------------------------------------------------------------------------------- /watch-party/components/Video/VideoPlayer.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 143 | 144 | 149 | -------------------------------------------------------------------------------- /watch-party/layouts/README.md: -------------------------------------------------------------------------------- 1 | # LAYOUTS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your Application Layouts. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/views#layouts). 8 | -------------------------------------------------------------------------------- /watch-party/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 60 | -------------------------------------------------------------------------------- /watch-party/middleware/README.md: -------------------------------------------------------------------------------- 1 | # MIDDLEWARE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your application middleware. 6 | Middleware let you define custom functions that can be run before rendering either a page or a group of pages. 7 | 8 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing#middleware). 9 | -------------------------------------------------------------------------------- /watch-party/nuxt.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // Target: https://go.nuxtjs.dev/config-target 3 | target: "static", 4 | 5 | // Global page headers: https://go.nuxtjs.dev/config-head 6 | head: { 7 | title: "Live watch party - JAMstack", 8 | htmlAttrs: { 9 | lang: "en" 10 | }, 11 | meta: [ 12 | { charset: "utf-8" }, 13 | { name: "viewport", content: "width=device-width, initial-scale=1" }, 14 | { hid: "description", name: "description", content: "" } 15 | ], 16 | link: [ 17 | { rel: "icon", type: "image/svg+xml", href: "https://static.ably.dev/motif-red.svg?jamstack-sync-stream-video" }, 18 | ] 19 | }, 20 | 21 | // Global CSS: https://go.nuxtjs.dev/config-css 22 | css: ["video.js/dist/video-js.css"], 23 | 24 | // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins 25 | plugins: [{ src: "~plugins/nuxt-video-player-plugin.js", ssr: false }], 26 | 27 | // Auto import components: https://go.nuxtjs.dev/config-components 28 | components: true, 29 | 30 | publicRuntimeConfig: { 31 | API_URL: process.env.API_URL 32 | }, 33 | 34 | // Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules 35 | buildModules: ["@nuxtjs/tailwindcss", "@nuxtjs/fontawesome", "@nuxtjs/dotenv"], 36 | 37 | fontawesome: { 38 | icons: { 39 | solid: true, 40 | brands: true 41 | } 42 | }, 43 | // Modules: https://go.nuxtjs.dev/config-modules 44 | modules: ["vue-github-buttons/nuxt",'@nuxtjs/apollo'], 45 | 46 | apollo: { 47 | clientConfigs: { 48 | default: { 49 | httpEndpoint: process.env.API_URL + '/graphql', 50 | } 51 | } 52 | }, 53 | 54 | // Build Configuration: https://go.nuxtjs.dev/config-build 55 | build: {} 56 | }; 57 | -------------------------------------------------------------------------------- /watch-party/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "watch-party", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "nuxt", 7 | "build": "nuxt build", 8 | "start": "nuxt start", 9 | "generate": "nuxt generate" 10 | }, 11 | "dependencies": { 12 | "@fortawesome/fontawesome-svg-core": "^1.2.35", 13 | "@nuxtjs/apollo": "^4.0.1-rc.5", 14 | "@nuxtjs/dotenv": "^1.4.1", 15 | "@tailwindcss/forms": "^0.2.1", 16 | "ably": "^1.2.5", 17 | "apollo-cache-inmemory": "^1.6.6", 18 | "core-js": "^3.8.3", 19 | "graphql": "^15.5.0", 20 | "graphql-tag": "^2.11.0", 21 | "nuxt": "^2.14.12", 22 | "nuxt-fontawesome": "^0.4.0", 23 | "videojs-youtube": "^2.6.1", 24 | "vue-github-buttons": "^3.1.0", 25 | "vue-video-player": "^5.0.2" 26 | }, 27 | "devDependencies": { 28 | "@fortawesome/free-brands-svg-icons": "^5.15.3", 29 | "@fortawesome/free-solid-svg-icons": "^5.15.3", 30 | "@nuxtjs/fontawesome": "^1.1.2", 31 | "@nuxtjs/tailwindcss": "^3.4.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /watch-party/pages/README.md: -------------------------------------------------------------------------------- 1 | # PAGES 2 | 3 | This directory contains your Application Views and Routes. 4 | The framework reads all the `*.vue` files inside this directory and creates the router of your application. 5 | 6 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing). 7 | -------------------------------------------------------------------------------- /watch-party/pages/gallery.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 99 | 100 | 168 | -------------------------------------------------------------------------------- /watch-party/pages/index.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 104 | 105 | 149 | -------------------------------------------------------------------------------- /watch-party/pages/room.vue: -------------------------------------------------------------------------------- 1 | 93 | 94 | 186 | 187 | 290 | -------------------------------------------------------------------------------- /watch-party/plugins/README.md: -------------------------------------------------------------------------------- 1 | # PLUGINS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains Javascript plugins that you want to run before mounting the root Vue.js application. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/plugins). 8 | -------------------------------------------------------------------------------- /watch-party/plugins/nuxt-video-player-plugin.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | 3 | const VueVideoPlayer = require("vue-video-player/dist/ssr"); 4 | Vue.use(VueVideoPlayer); 5 | -------------------------------------------------------------------------------- /watch-party/static/README.md: -------------------------------------------------------------------------------- 1 | # STATIC 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your static files. 6 | Each file inside this directory is mapped to `/`. 7 | Thus you'd want to delete this README.md before deploying to production. 8 | 9 | Example: `/static/robots.txt` is mapped as `/robots.txt`. 10 | 11 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#static). 12 | -------------------------------------------------------------------------------- /watch-party/store/README.md: -------------------------------------------------------------------------------- 1 | # STORE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your Vuex Store files. 6 | Vuex Store option is implemented in the Nuxt.js framework. 7 | 8 | Creating a file in this directory automatically activates the option in the framework. 9 | 10 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/vuex-store). 11 | -------------------------------------------------------------------------------- /watch-party/store/index.js: -------------------------------------------------------------------------------- 1 | import Vuex from "vuex"; 2 | import * as Ably from "ably"; 3 | 4 | const createStore = () => { 5 | return new Vuex.Store({ 6 | state: { 7 | ablyRealtimeInstance: null, 8 | isAblyConnected: false, 9 | ablyClientId: null, 10 | watchPartyRoomCode: null, 11 | shouldShowShareableCodeStatus: false, 12 | channelNames: { 13 | mainParty: "partych", 14 | comments: "comments", 15 | video: "video" 16 | }, 17 | channelInstances: { 18 | mainParty: null, 19 | comments: null, 20 | video: null 21 | }, 22 | channelMessages: { 23 | mainPartyChMsg: null, 24 | commentsChMsg: null, 25 | videoChMsg: null 26 | }, 27 | username: null, 28 | isAdmin: false, 29 | shareableLink: null, 30 | presenceCount: 0, 31 | didAdminChooseVideo: false, 32 | chosenVideoRef: null, 33 | didAdminLeave: false, 34 | onlineMembersArr: [], 35 | currentVideoStatus: { 36 | isVideoChosen: false, 37 | didStartPlayingVideo: false, 38 | chosenVideoUrl: null, 39 | chosenVideoRef: null, 40 | chosenVideoThumb: null, 41 | currentTime: null, 42 | isPlaying: false, 43 | isPaused: false 44 | }, 45 | videoPlayerInstance: null, 46 | defaultVideoPlayerOptions: { 47 | //player configuration ml-6 w-11/12 container 48 | muted: false, //whether to mute 49 | language: "en", 50 | fluid: true, 51 | // width: "550px", 52 | // height: "300px", 53 | liveui: true, 54 | playbackRates: [0.7, 1.0, 1.5, 2.0], //Playback speed 55 | sources: [ 56 | { 57 | type: "video/mp4", 58 | src: 59 | "https://res.cloudinary.com/dlaq5yfxp/video/upload/v1618305819/150716YesMen_synd_768k_vp8_w0dpbg.webm" 60 | } 61 | ], 62 | poster: 63 | "https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885_1280.jpg" //Cover image 64 | } 65 | }, 66 | getters: { 67 | getShouldShowShareableCodeStatus: state => 68 | state.shouldShowShareableCodeStatus, 69 | getWatchPartyRoomCode: state => state.watchPartyRoomCode, 70 | getShareableLink: state => state.shareableLink, 71 | getIsAdminStatus: state => state.isAdmin, 72 | getPartyChInstance: state => state.channelInstances.mainParty, 73 | getCommentsChInstance: state => state.channelInstances.comments, 74 | getVideoChInstance: state => state.channelInstances.video, 75 | getMainPartyChMessage: state => state.channelMessages.mainPartyChMsg, 76 | getCommentsChMessage: state => state.channelMessages.commentsChMsg, 77 | getVideoChMessage: state => state.channelMessages.videoChMsg, 78 | getIsAblyConnectedStatus: state => state.isAblyConnected, 79 | getPresenceCount: state => state.presenceCount, 80 | getUsername: state => state.username, 81 | getDidAdminChooseVideoStatus: state => state.didAdminChooseVideo, 82 | getChosenVideoRef: state => state.currentVideoStatus.chosenVideoRef, 83 | getChosenVideoUrl: state => state.currentVideoStatus.chosenVideoUrl, 84 | getChosenVideoThumb: state => state.currentVideoStatus.chosenVideoThumb, 85 | getDidAdminLeaveStatus: state => state.didAdminLeave, 86 | getCurrentVideoStatus: state => state.currentVideoStatus, 87 | getOnlineMembersArr: state => state.onlineMembersArr, 88 | getLatestVideoPlayerOptions: state => { 89 | let latestVideoPlayerOptions = state.defaultVideoPlayerOptions; 90 | latestVideoPlayerOptions.sources[0].src = 91 | state.currentVideoStatus.chosenVideoUrl; 92 | latestVideoPlayerOptions.poster = 93 | state.currentVideoStatus.chosenVideoThumb; 94 | return latestVideoPlayerOptions; 95 | } 96 | }, 97 | 98 | mutations: { 99 | setAblyRealtimeInstance(state, ablyInstance) { 100 | state.ablyRealtimeInstance = ablyInstance; 101 | }, 102 | setAblyConnectionStatus(state, status) { 103 | state.isAblyConnected = status; 104 | }, 105 | setAblyClientId(state, id) { 106 | state.ablyClientId = id; 107 | }, 108 | setWatchPartyRoomCode(state, code) { 109 | state.watchPartyRoomCode = code; 110 | }, 111 | setUsername(state, username) { 112 | state.username = username; 113 | }, 114 | setIsAdminStatus(state, isAdmin) { 115 | state.isAdmin = isAdmin; 116 | }, 117 | setAblyChannelInstances(state, { mainParty, comments, video }) { 118 | state.channelInstances.mainParty = mainParty; 119 | state.channelInstances.comments = comments; 120 | state.channelInstances.video = video; 121 | }, 122 | setShouldShowCodeStatus(state, status) { 123 | state.shouldShowShareableCodeStatus = status; 124 | }, 125 | setShareableLink(state, link) { 126 | state.shareableLink = link; 127 | }, 128 | setPresenceCount(state, count) { 129 | state.presenceCount = count; 130 | }, 131 | setPresenceIncrement(state) { 132 | state.presenceCount++; 133 | }, 134 | setPresenceDecrement(state) { 135 | state.presenceCount--; 136 | }, 137 | setAdminLeaveStatus(state) { 138 | state.didAdminLeave = true; 139 | }, 140 | setOnlineMembersArrInsert(state, memberObj) { 141 | state.onlineMembersArr.push(memberObj); 142 | }, 143 | setOnlineMembersArrRemoval(state, clientId) { 144 | state.onlineMembersArr.splice( 145 | state.onlineMembersArr.findIndex( 146 | presenceEntry => presenceEntry.id === clientId 147 | ), 148 | 1 149 | ); 150 | }, 151 | setVideoStatusUpdate(state, statusObj) { 152 | for (const key in statusObj) { 153 | state.currentVideoStatus[key] = statusObj[key]; 154 | } 155 | }, 156 | setVideoPlayerInstance(state, instance) { 157 | state.videoPlayerInstance = instance; 158 | } 159 | }, 160 | 161 | actions: { 162 | //Ably init 163 | instantiateAbly(vueContext, { username, isAdmin }) { 164 | const ablyInstance = new Ably.Realtime({ 165 | authUrl: this.$config.API_URL + "/auth-ably" 166 | // echoMessages: false 167 | }); 168 | ablyInstance.connection.once("connected", () => { 169 | vueContext.commit("setAblyConnectionStatus", true); 170 | vueContext.commit("setAblyRealtimeInstance", ablyInstance); 171 | vueContext.commit( 172 | "setAblyClientId", 173 | this.state.ablyRealtimeInstance.auth.clientId 174 | ); 175 | vueContext.commit("setUsername", username); 176 | vueContext.commit("setIsAdminStatus", isAdmin); 177 | if (isAdmin) { 178 | vueContext.dispatch("generateWatchPartyCode"); 179 | } 180 | vueContext.dispatch("attachToAblyChannels", isAdmin); 181 | vueContext.dispatch("getExistingAblyPresenceSet"); 182 | vueContext.dispatch("subscribeToAblyPresence"); 183 | vueContext.dispatch("enterClientInAblyPresenceSet"); 184 | }); 185 | }, 186 | attachToAblyChannels(vueContext, isAdmin) { 187 | //mainPartyChannel 188 | const mainParty = this.state.ablyRealtimeInstance.channels.get( 189 | this.state.channelNames.mainParty + 190 | "-" + 191 | this.state.watchPartyRoomCode 192 | ); 193 | 194 | //commentsChannel 195 | const comments = this.state.ablyRealtimeInstance.channels.get( 196 | this.state.channelNames.comments + 197 | "-" + 198 | this.state.watchPartyRoomCode, 199 | { 200 | params: { rewind: "5m" } 201 | } 202 | ); 203 | 204 | //videoChannel 205 | const video = this.state.ablyRealtimeInstance.channels.get( 206 | this.state.channelNames.video + "-" + this.state.watchPartyRoomCode 207 | ); 208 | vueContext.commit("setAblyChannelInstances", { 209 | mainParty, 210 | comments, 211 | video 212 | }); 213 | 214 | vueContext.dispatch("subscribeToChannels"); 215 | if (!isAdmin) { 216 | vueContext.dispatch("requestInitialVideoStatus"); 217 | } 218 | }, 219 | 220 | subscribeToChannels({ state, dispatch }) { 221 | state.channelInstances.comments.subscribe(msg => { 222 | state.channelMessages.commentsChMsg = msg; 223 | }); 224 | state.channelInstances.mainParty.subscribe(msg => { 225 | state.channelMessages.mainPartyChMsg = msg; 226 | }); 227 | state.channelInstances.video.subscribe(msg => { 228 | if (msg.name === "general-status-request" && state.isAdmin) { 229 | dispatch("publishCurrentVideoStatus", "general-status"); 230 | } else if (!state.isAdmin && msg.name !== "general-status-request") { 231 | state.channelMessages.videoChMsg = msg; 232 | } 233 | }); 234 | }, 235 | publishCurrentVideoStatus({ state }, updateEvent) { 236 | console.log("ADMIN PUBLISHING", updateEvent); 237 | state.channelInstances.video.publish( 238 | updateEvent, 239 | this.state.currentVideoStatus 240 | ); 241 | }, 242 | getExistingAblyPresenceSet(vueContext) { 243 | this.state.channelInstances.mainParty.presence.get((err, members) => { 244 | let isAdminMissing = true; 245 | if (!err) { 246 | for (let i = 0; i < members.length; i++) { 247 | let { username, isAdmin } = members[i].data; 248 | if (isAdmin) { 249 | isAdminMissing = false; 250 | } 251 | vueContext.commit("setOnlineMembersArrInsert", { 252 | clientId: members[i].clientId, 253 | username, 254 | isAdmin 255 | }); 256 | } 257 | if (isAdminMissing && !vueContext.state.isAdmin) { 258 | vueContext.commit("setAdminLeaveStatus"); 259 | } 260 | console.log(members); 261 | vueContext.commit("setPresenceCount", members.length); 262 | } else { 263 | console.log(err); 264 | } 265 | }); 266 | }, 267 | subscribeToAblyPresence(vueContext) { 268 | this.state.channelInstances.mainParty.presence.subscribe( 269 | "enter", 270 | msg => { 271 | vueContext.dispatch("handleNewMemberEntered", msg); 272 | } 273 | ); 274 | this.state.channelInstances.mainParty.presence.subscribe( 275 | "leave", 276 | msg => { 277 | vueContext.dispatch("handleExistingMemberLeft", msg); 278 | } 279 | ); 280 | }, 281 | handleNewMemberEntered(vueContext, member) { 282 | vueContext.commit("setPresenceIncrement"); 283 | vueContext.commit("setOnlineMembersArrInsert", { 284 | id: member.clientId, 285 | username: member.data.username, 286 | isAdmin: member.data.isAdmin 287 | }); 288 | }, 289 | handleExistingMemberLeft(vueContext, member) { 290 | if (member.data.isAdmin) { 291 | vueContext.commit("setAdminLeaveStatus"); 292 | } 293 | vueContext.commit("setOnlineMembersArrRemoval", member.id); 294 | vueContext.commit("setPresenceDecrement"); 295 | }, 296 | enterClientInAblyPresenceSet(vueContext) { 297 | this.state.channelInstances.mainParty.presence.enter({ 298 | username: this.state.username, 299 | isAdmin: this.state.isAdmin 300 | }); 301 | if (this.state.isAdmin) { 302 | vueContext.dispatch("showShareableCode"); 303 | } 304 | }, 305 | requestInitialVideoStatus({ state }) { 306 | state.channelInstances.video.publish( 307 | "general-status-request", 308 | "request" 309 | ); 310 | }, 311 | publishMyCommentToAbly({ state }, commentMsg) { 312 | state.channelInstances.comments.publish("comment", { 313 | username: state.username, 314 | content: commentMsg 315 | }); 316 | }, 317 | //Utility methods 318 | showShareableCode(vueContext) { 319 | vueContext.commit("setShouldShowCodeStatus", true); 320 | }, 321 | generateWatchPartyCode(vueContext) { 322 | const uniqueCode = Math.random() 323 | .toString(36) 324 | .substr(2, 16); 325 | vueContext.commit("setWatchPartyRoomCode", uniqueCode); 326 | } 327 | } 328 | }); 329 | }; 330 | 331 | export default createStore; 332 | --------------------------------------------------------------------------------