├── .env.example ├── .eslintrc.json ├── .github ├── funding.yml └── workflows │ └── sentry.yml ├── .gitignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── backend ├── content.ts ├── index.ts ├── room.ts ├── search.ts ├── sentry.ts ├── server.ts ├── util.ts └── youtube.ts ├── index.html ├── license.md ├── netlify.toml ├── netlify ├── deploy-failed.js └── deploy-succeeded.js ├── nodemon.json ├── package-lock.json ├── package.json ├── pm2-staging.json ├── pm2.json ├── public ├── favicon-dark.ico ├── favicon-dark.svg ├── favicon.ico ├── favicon.svg └── thumbnail.svg ├── src ├── App.vue ├── assets │ ├── fonts │ │ ├── Manrope.woff2 │ │ ├── RobotoMonoTimestamp.woff2 │ │ └── fonts.css │ ├── icons │ │ ├── cached.svg │ │ ├── close.svg │ │ ├── content_copy.svg │ │ ├── content_paste.svg │ │ ├── content_paste_time.svg │ │ ├── fullscreen.svg │ │ ├── fullscreen_exit.svg │ │ ├── open_in_new.svg │ │ ├── pause.svg │ │ ├── play_arrow.svg │ │ ├── playlist.svg │ │ ├── skip_next.svg │ │ ├── volume_down.svg │ │ ├── volume_mute.svg │ │ ├── volume_off.svg │ │ └── volume_up.svg │ └── theme.css ├── components │ ├── LoadingSpinner.vue │ ├── NavBar.vue │ ├── PlayerControls.vue │ ├── PlayerWrapper.vue │ ├── ResyncInput.ts │ ├── ResyncLogo.ts │ ├── ResyncSlider.vue │ ├── SvgIcon.vue │ ├── VideoList.vue │ └── VideoPlayer.ts ├── main.ts ├── mediaSession.ts ├── notify.ts ├── resync.ts ├── router.ts ├── shortcuts.ts ├── util.ts └── views │ ├── ResyncHome.vue │ ├── ResyncRoom.vue │ └── ResyncSignup.vue ├── tailwind.config.ts ├── tsconfig.backend.json ├── tsconfig.json ├── types ├── MediaSession.d.ts ├── mediaSource.d.ts ├── room.d.ts └── socket.d.ts └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | BACKEND_PORT=3020 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "vue-eslint-parser", 3 | "root": true, 4 | "env": { 5 | "node": true 6 | }, 7 | "parserOptions": { 8 | "sourceType": "module", 9 | "ecmaVersion": 2020, 10 | "parser": "@typescript-eslint/parser" 11 | }, 12 | "plugins": ["@typescript-eslint"], 13 | "extends": [ 14 | "eslint:recommended", 15 | "plugin:@typescript-eslint/recommended", 16 | "plugin:vue/vue3-recommended", 17 | "plugin:prettier/recommended" 18 | ], 19 | "ignorePatterns": "**/*.html", 20 | "rules": { 21 | "@typescript-eslint/consistent-type-imports": "error", 22 | "@typescript-eslint/ban-ts-comment": ["error", { "minimumDescriptionLength": 5 }], 23 | "eqeqeq": "error", 24 | "@typescript-eslint/member-delimiter-style": [ 25 | "error", 26 | { "multiline": { "delimiter": "none" } } 27 | ], 28 | "@typescript-eslint/indent": ["error", 2], 29 | "@typescript-eslint/no-explicit-any": "off", 30 | "prettier/prettier": ["error", { "endOfLine": "auto" }] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: vaaski # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/sentry.yml: -------------------------------------------------------------------------------- 1 | name: sentry 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | sentry: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: create sentry release 14 | uses: getsentry/action-release@v1 15 | env: 16 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 17 | SENTRY_ORG: ${{ secrets.SENTRY_ORG }} 18 | SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} 19 | with: 20 | environment: production 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | .DS_Store 4 | dist 5 | dist-ssr 6 | *.local 7 | .env 8 | # Local Netlify folder 9 | .netlify -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "es5", 4 | "arrowParens": "avoid", 5 | "printWidth": 95, 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "singleQuote": false 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "voorjaar.windicss-intellisense", 4 | "Vue.volar", 5 | "Vue.vscode-typescript-vue-plugin", 6 | "visualstudioexptteam.vscodeintellicode", 7 | "knisterpeter.vscode-commitizen", 8 | "christian-kohler.npm-intellisense", 9 | "eamodio.gitlens", 10 | "dbaeumer.vscode-eslint", 11 | "streetsidesoftware.code-spell-checker", 12 | "aaron-bond.better-comments" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["Windi", "resync'd", "windicss", "youtu"], 3 | "windicss.sortOnSave": true, 4 | "windicss.enableCodeFolding": false, 5 | "typescript.tsdk": "node_modules/typescript/lib", 6 | "eslint.validate": ["typescript", "vue"], 7 | "json.schemas": [ 8 | { 9 | "fileMatch": ["pm2.json", "pm2-staging.json"], 10 | "url": "https://raw.githubusercontent.com/edosssa/pm2-config/master/pm2config.json" 11 | }, 12 | { 13 | "fileMatch": ["nodemon.json"], 14 | "url": "https://raw.githubusercontent.com/SchemaStore/schemastore/master/src/schemas/json/nodemon.json" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /backend/content.ts: -------------------------------------------------------------------------------- 1 | import { getVideoID } from "@distube/ytdl-core" 2 | import type { MediaSourceAny } from "../types/mediaSource" 3 | import { getCombinedStream, getInfo } from "./youtube" 4 | 5 | export const resolveContent = async ( 6 | url: string, 7 | startFrom: number 8 | ): Promise => { 9 | if (url.match(/youtube\.com|youtu\.be/)) { 10 | const video = await getCombinedStream(url) 11 | const { title, author, lengthSeconds } = await getInfo(url) 12 | 13 | return { 14 | platform: "youtube", 15 | startFrom, 16 | video, 17 | title, 18 | duration: parseInt(lengthSeconds), 19 | uploader: author.name, 20 | thumb: `https://i.ytimg.com/vi/${getVideoID(url)}/mqdefault.jpg`, 21 | type: "video", 22 | originalSource: { url, youtubeID: getVideoID(url) }, 23 | } 24 | } 25 | 26 | return { 27 | startFrom, 28 | // TODO 29 | duration: 0, 30 | platform: "other", 31 | video: [{ quality: "default", url }], 32 | title: `content from ${new URL(url).hostname}`, 33 | type: "video", 34 | originalSource: { url }, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /backend/index.ts: -------------------------------------------------------------------------------- 1 | console.log("starting resync") 2 | import "./sentry" 3 | 4 | import server from "./server" 5 | 6 | let port = Number(process.env.BACKEND_PORT ?? 3020) 7 | if (process.env.NODE_ENV === "staging") port = Number(process.env.STAGING_PORT ?? 6969) 8 | 9 | server(port).then(() => console.log(`resync listening on ${port}`)) 10 | -------------------------------------------------------------------------------- /backend/room.ts: -------------------------------------------------------------------------------- 1 | import type { BroadcastOperator, Server, Socket } from "socket.io" 2 | import type { MediaSourceAny } from "/$/mediaSource" 3 | import type { NotifyEvents, RoomState, Member, EventNotification } from "/$/room" 4 | import type { BackendEmits, ResyncSocketBackend } from "/$/socket" 5 | 6 | import { average } from "./util" 7 | import { customAlphabet } from "nanoid" 8 | import { nolookalikesSafe } from "nanoid-dictionary" 9 | 10 | const nanoid = customAlphabet(nolookalikesSafe, 6) 11 | 12 | import { resolveContent } from "./content" 13 | 14 | import debug from "debug" 15 | const log = debug("resync:room") 16 | 17 | const rooms: Record = {} 18 | const getNewRandom = () => { 19 | let id = nanoid() 20 | while (rooms[id]) id = nanoid() 21 | 22 | return id 23 | } 24 | 25 | interface PlaybackErrorArg { 26 | client: Socket 27 | reason: string 28 | name: string 29 | } 30 | 31 | class Room { 32 | readonly roomID: string 33 | private io: ResyncSocketBackend 34 | private log: debug.Debugger 35 | readonly broadcast: BroadcastOperator 36 | 37 | public members: Array = [] 38 | 39 | paused = true 40 | lastSeekedTo = 0 41 | source: MediaSourceAny | undefined 42 | queue: Promise[] = [] 43 | membersLoading = 0 44 | membersPlaying = 0 45 | 46 | constructor(roomID: string, io: Server) { 47 | log(`constructing room ${roomID}`) 48 | 49 | this.roomID = roomID 50 | this.io = io 51 | this.broadcast = this.io.to(roomID) 52 | this.log = log.extend(roomID) 53 | } 54 | 55 | private notify(event: NotifyEvents, client: Socket, additional?: any) { 56 | const { id } = client 57 | let name = id 58 | 59 | const member = this.getMember(id) 60 | if (member) name = member.name 61 | 62 | const notification: EventNotification = { 63 | event, 64 | id, 65 | name, 66 | additional, 67 | key: nanoid(), 68 | } 69 | this.broadcast.emit("notifiy", notification) 70 | this.log(`[${event}](${name})`, additional || "") 71 | } 72 | 73 | message(msg: string, client: Socket) { 74 | const { id } = client 75 | let name = id 76 | 77 | const member = this.getMember(id) 78 | if (member) name = member.name 79 | 80 | const msgObj = { 81 | name, 82 | msg, 83 | key: nanoid(), 84 | } 85 | 86 | this.broadcast.emit("message", msgObj) 87 | } 88 | 89 | get state(): Promise { 90 | return (async () => { 91 | return { 92 | paused: this.paused, 93 | source: this.source, 94 | lastSeekedTo: this.lastSeekedTo, 95 | members: this.members.map(({ client: { id }, name }) => ({ id, name })), 96 | membersLoading: this.membersLoading, 97 | queue: await Promise.all(this.queue), 98 | } 99 | })() 100 | } 101 | 102 | getMember = (id: string) => this.members.find(m => m.client.id === id) 103 | removeMember = (id: string) => (this.members = this.members.filter(m => m.client.id !== id)) 104 | 105 | async updateState() { 106 | this.broadcast.emit("state", await this.state) 107 | } 108 | 109 | join(client: Socket, name: string) { 110 | this.members.push({ client, name }) 111 | client.join(this.roomID) 112 | 113 | client.on("disconnect", () => this.leave(client)) 114 | 115 | this.updateState() 116 | this.notify("join", client) 117 | } 118 | 119 | leave(client: Socket) { 120 | this.notify("leave", client) 121 | 122 | client.leave(this.roomID) 123 | this.removeMember(client.id) 124 | 125 | const memberAmount = Object.keys(this.members).length 126 | if (memberAmount <= 0) this.paused = true 127 | 128 | this.updateState() 129 | } 130 | 131 | async playContent( 132 | client: Socket | undefined, 133 | source: string | Promise, 134 | startFrom: number 135 | ) { 136 | let sourceID = "" 137 | const currentSourceID = 138 | this.source?.originalSource.youtubeID ?? this.source?.originalSource.url 139 | 140 | if (typeof source === "string") { 141 | this.source = source ? await resolveContent(source, startFrom) : undefined 142 | if (this.source) { 143 | sourceID = this.source.originalSource.youtubeID ?? this.source.originalSource.url 144 | } 145 | } else { 146 | this.source = await source 147 | sourceID = this.source.originalSource.youtubeID ?? this.source.originalSource.url 148 | } 149 | 150 | if (sourceID === currentSourceID) { 151 | this.log("same video") 152 | 153 | this.lastSeekedTo = 0 154 | this.seekTo({ client, seconds: 0 }) 155 | this.resume() 156 | return 157 | } 158 | 159 | this.membersLoading = this.members.length 160 | this.membersPlaying = this.members.length 161 | this.lastSeekedTo = startFrom 162 | this.paused = true 163 | this.broadcast.emit("source", this.source) 164 | 165 | this.updateState() 166 | if (client) this.notify("playContent", client, { source, startFrom }) 167 | } 168 | 169 | addQueue(client: Socket, source: string, startFrom: number) { 170 | this.queue.push(resolveContent(source, startFrom)) 171 | 172 | this.updateState() 173 | this.notify("queue", client) 174 | } 175 | 176 | clearQueue(client: Socket) { 177 | this.queue = [] 178 | 179 | this.updateState() 180 | this.notify("clearQueue", client) 181 | } 182 | 183 | playQueued(client: Socket, index: number, remove = false) { 184 | const [next] = this.queue.splice(index, 1) 185 | if (!next) return this.log("client requested non-existant item from queue") 186 | 187 | if (remove) { 188 | this.notify("removeQueued", client) 189 | this.updateState() 190 | } else this.playContent(client, next, 0) 191 | } 192 | 193 | loaded() { 194 | this.membersLoading-- 195 | this.updateState() 196 | 197 | if (this.membersLoading <= 0) this.resume() 198 | this.log(`members loading: ${this.membersLoading}`) 199 | } 200 | 201 | finished() { 202 | this.membersPlaying-- 203 | this.log(`members playing: ${this.membersPlaying}`) 204 | 205 | if (this.membersPlaying <= 0) { 206 | const next = this.queue.shift() 207 | if (next) return this.playContent(undefined, next, 0) 208 | 209 | this.playContent(undefined, "", 0) 210 | } 211 | } 212 | 213 | pause(seconds?: number, client?: Socket) { 214 | this.paused = true 215 | this.broadcast.emit("pause") 216 | 217 | if (seconds) this.seekTo({ seconds }) 218 | 219 | this.updateState() 220 | if (client) this.notify("pause", client) 221 | } 222 | 223 | resume(client?: Socket) { 224 | this.paused = false 225 | this.broadcast.emit("resume") 226 | 227 | this.updateState() 228 | if (client) this.notify("resume", client) 229 | } 230 | 231 | seekTo({ client, seconds }: { client?: Socket; seconds: number }) { 232 | this.lastSeekedTo = seconds 233 | this.broadcast.emit("seekTo", seconds) 234 | 235 | this.updateState() 236 | if (client) this.notify("seekTo", client, { seconds }) 237 | } 238 | 239 | async requestTime(client: Socket) { 240 | const requestTimeLog = this.log.extend("requestTime") 241 | requestTimeLog("requested time") 242 | 243 | const sockets = await this.broadcast.allSockets() 244 | const otherClients = [...sockets].filter(s => s !== client.id) 245 | 246 | const getTime = (sock: Socket): Promise => 247 | new Promise(res => sock.emit("requestTime", res)) 248 | 249 | const times = [] 250 | 251 | for (const id of otherClients) { 252 | const member = this.getMember(id) 253 | if (!member) { 254 | requestTimeLog.extend("error")(`id ${id} not found in clients`) 255 | continue 256 | } 257 | 258 | const time = await getTime(member.client) 259 | requestTimeLog(`${id} responded with time ${time}`) 260 | times.push(time) 261 | } 262 | 263 | const avg = average(...times) 264 | 265 | requestTimeLog("times", times, "avg", avg) 266 | 267 | return avg 268 | } 269 | 270 | async resync(client: Socket) { 271 | this.pause() 272 | 273 | const avg = await this.requestTime(client) 274 | this.seekTo({ seconds: avg }) 275 | this.resume() 276 | 277 | this.updateState() 278 | this.notify("resync", client) 279 | } 280 | 281 | playbackError({ client, reason, name }: PlaybackErrorArg, seconds: number) { 282 | this.notify("playbackError", client, { reason, name }) 283 | this.pause() 284 | this.seekTo({ seconds }) 285 | this.updateState() 286 | } 287 | } 288 | 289 | export default (io: ResyncSocketBackend): void => { 290 | const getRoom = (roomID: string) => { 291 | if (!rooms[roomID]) rooms[roomID] = new Room(roomID, io) 292 | return rooms[roomID] 293 | } 294 | 295 | io.on("connect", client => { 296 | client.on("message", ({ msg, roomID }) => { 297 | getRoom(roomID).message(msg, client) 298 | }) 299 | 300 | client.on("joinRoom", async ({ roomID, name }, reply) => { 301 | const room = getRoom(roomID) 302 | room.join(client, name) 303 | 304 | reply(await room.state) 305 | }) 306 | 307 | client.on("leaveRoom", ({ roomID }) => { 308 | getRoom(roomID).leave(client) 309 | }) 310 | 311 | client.on("playContent", ({ roomID, source, startFrom = 0 }) => { 312 | getRoom(roomID).playContent(client, source, startFrom) 313 | }) 314 | 315 | client.on("queue", ({ roomID, source, startFrom = 0 }) => { 316 | getRoom(roomID).addQueue(client, source, startFrom) 317 | }) 318 | 319 | client.on("clearQueue", ({ roomID }) => getRoom(roomID).clearQueue(client)) 320 | 321 | client.on("playQueued", ({ roomID, index }) => { 322 | getRoom(roomID).playQueued(client, index) 323 | }) 324 | 325 | client.on("removeQueued", ({ roomID, index }) => { 326 | getRoom(roomID).playQueued(client, index, true) 327 | }) 328 | 329 | client.on("loaded", ({ roomID }) => getRoom(roomID).loaded()) 330 | client.on("finished", ({ roomID }) => getRoom(roomID).finished()) 331 | 332 | client.on("pause", ({ roomID, currentTime }) => { 333 | getRoom(roomID).pause(currentTime, client) 334 | }) 335 | 336 | client.on("resume", ({ roomID }) => { 337 | getRoom(roomID).resume(client) 338 | }) 339 | 340 | client.on("seekTo", ({ roomID, currentTime }) => { 341 | getRoom(roomID).seekTo({ client, seconds: currentTime }) 342 | }) 343 | 344 | client.on("resync", ({ roomID }) => getRoom(roomID).resync(client)) 345 | 346 | client.on("playbackError", ({ roomID, reason, currentTime, name }) => { 347 | getRoom(roomID).playbackError({ client, reason, name }, currentTime) 348 | }) 349 | 350 | client.on("getNewRandom", reply => reply(getNewRandom())) 351 | }) 352 | } 353 | -------------------------------------------------------------------------------- /backend/search.ts: -------------------------------------------------------------------------------- 1 | import type { ResyncSocketBackend } from "/$/socket" 2 | import type { MediaSourceAny } from "/$/mediaSource" 3 | 4 | import ytsr from "ytsr" 5 | 6 | import debug from "debug" 7 | import { timestampToDuration } from "./util" 8 | const log = debug("resync:search") 9 | 10 | const cacheExpire = 60e3 * 5 11 | const opt: ytsr.Options = { 12 | limit: 25, 13 | } 14 | 15 | const transform = (item: ytsr.Item): MediaSourceAny => { 16 | const video = item as ytsr.Video 17 | 18 | return { 19 | duration: timestampToDuration(video.duration ?? "0"), 20 | originalSource: { 21 | url: `https://youtu.be/${video.id}`, 22 | youtubeID: video.id, 23 | }, 24 | platform: "youtube", 25 | startFrom: 0, 26 | title: video.title, 27 | type: "video", 28 | thumb: video.bestThumbnail?.url ?? undefined, 29 | uploader: video.author?.name ?? undefined, 30 | } 31 | } 32 | 33 | export interface Cached { 34 | result: MediaSourceAny[] 35 | expire: Date 36 | } 37 | const cached: Record = {} 38 | 39 | const search = async (query: string): Promise => { 40 | if (new Date() < cached[query]?.expire) { 41 | log("returning cached search result") 42 | return cached[query].result 43 | } 44 | 45 | const filters = await ytsr.getFilters(query) 46 | const videos = filters.get("Type")?.get("Video") 47 | 48 | if (!videos?.url) throw Error("video filter is falsy") 49 | 50 | const items = await ytsr(videos.url, opt) 51 | const result = items.items.map(transform) 52 | 53 | cached[query] = { 54 | expire: new Date(Date.now() + cacheExpire), 55 | result, 56 | } 57 | 58 | return result 59 | } 60 | 61 | export default (io: ResyncSocketBackend): void => { 62 | io.on("connect", async client => { 63 | client.on("search", async (query, reply) => { 64 | log(`searching for ${query}`) 65 | 66 | return reply(await search(query)) 67 | }) 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /backend/sentry.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/node" 2 | 3 | import debug from "debug" 4 | const log = debug("resync:sentry") 5 | 6 | if (process.env.NODE_ENV !== "development") { 7 | Sentry.init({ 8 | dsn: "https://5b4d331966544c5e823e1ea81f56e3cf@o105856.ingest.sentry.io/5712866", 9 | tracesSampleRate: 1.0, 10 | environment: process.env.NODE_ENV, 11 | }) 12 | log("initialized sentry.io") 13 | } 14 | -------------------------------------------------------------------------------- /backend/server.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "http" 2 | import { Server } from "socket.io" 3 | import room from "./room" 4 | import search from "./search" 5 | 6 | import debug from "debug" 7 | const log = debug("resync:server") 8 | 9 | const isDev = process.env.NODE_ENV === "development" 10 | log("dev", isDev) 11 | 12 | let origin = isDev ? "*" : ["https://resync.tv", /\.netlify\.app$/] 13 | if (process.env.NODE_ENV === "staging") { 14 | const STAGING_ORIGIN = process.env.STAGING_ORIGIN ?? "https://staging.resync.tv" 15 | origin = [STAGING_ORIGIN, /\.netlify\.app$/] 16 | } 17 | 18 | export default (port: number): Promise => { 19 | return new Promise((res, rej) => { 20 | { 21 | const httpServer = createServer() 22 | const io = new Server(httpServer, { 23 | cors: { origin }, 24 | }) 25 | 26 | io.on("connection", client => { 27 | log("client connected", client.id) 28 | client.on("disconnect", () => log("client disconnected", client.id)) 29 | }) 30 | 31 | room(io) 32 | search(io) 33 | 34 | httpServer.listen(port).on("listening", res).on("error", rej) 35 | } 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /backend/util.ts: -------------------------------------------------------------------------------- 1 | export const average = (...n: number[]): number => n.reduce((a, v) => a + v, 0) / n.length 2 | 3 | export const timestampToDuration = (timestamp: string): number => { 4 | const ordered = timestamp.split(":").reverse() 5 | if (!ordered.length) throw Error(`${timestamp} is not a valid timestamp`) 6 | 7 | let res = parseInt(ordered[0]) 8 | if (ordered[1]) res += parseInt(ordered[1]) * 60 9 | if (ordered[2]) res += parseInt(ordered[2]) * 60 * 60 10 | 11 | return res 12 | } 13 | 14 | export const once = ( 15 | fn: (this: T, ...arg: A) => R 16 | ): ((this: T, ...arg: A) => R | undefined) => { 17 | let done = false 18 | return function (this: T, ...args: A) { 19 | return done ? void 0 : ((done = true), fn.apply(this, args)) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/youtube.ts: -------------------------------------------------------------------------------- 1 | import type { MediaRawSource } from "../types/mediaSource" 2 | 3 | import ytdl_core from "@distube/ytdl-core" 4 | import { average, once } from "./util" 5 | 6 | import debug from "debug" 7 | const log = debug("resync:youtube") 8 | 9 | import type { yt_dl } from "@resync-tv/yt-dl" 10 | import YT_DL, { adapters, ensureBinaries } from "@resync-tv/yt-dl" 11 | const ytdlpAdapter = new adapters.ytdlp() 12 | const ytdl = new YT_DL([ytdl_core.getInfo, ytdlpAdapter.getInfo], "first-to-resolve") 13 | 14 | const ensureBinariesOnce = once(() => { 15 | log("ensuring binaries") 16 | return ensureBinaries(true) 17 | }) 18 | 19 | const urlExpire = (url: string): number => { 20 | const { searchParams } = new URL(url) 21 | const expires = searchParams.get("expire") 22 | 23 | if (!expires) return NaN 24 | else return Number(expires) 25 | } 26 | 27 | const transformFormat = (format: yt_dl.EnsuredVideoFormat): MediaRawSource => { 28 | return { 29 | url: format.url, 30 | quality: format.hasVideo ? format.quality : `${format.audioBitrate} kbps`, 31 | } 32 | } 33 | 34 | interface Cached { 35 | formats: yt_dl.EnsuredVideoFormat[] 36 | expires: Date 37 | videoDetails: yt_dl.EnsuredMoreVideoDetails 38 | } 39 | const cached: Record = {} 40 | 41 | const fetchVideo = async (source: string) => { 42 | const id = ytdl_core.getVideoID(source) 43 | 44 | if (cached[id]) { 45 | log(`cached formats found for ${id}`) 46 | 47 | if (new Date() > cached[id].expires) delete cached[id] 48 | else return cached[id] 49 | } 50 | 51 | log(`fetching formats for ${id}`) 52 | await ensureBinariesOnce() 53 | const { formats, videoDetails } = await ytdl.getInfo(id) 54 | const averageExpire = average(...formats.map(f => urlExpire(f.url)).filter(e => !isNaN(e))) 55 | const expires = new Date(averageExpire * 1e3) 56 | 57 | cached[id] = { formats, videoDetails, expires } 58 | 59 | return cached[id] 60 | } 61 | 62 | export const getCombinedStream = async (source: string): Promise => { 63 | const { formats } = await fetchVideo(source) 64 | 65 | const combined = formats.filter(f => f.hasAudio && f.hasVideo) 66 | const sorted = combined.sort((a, b) => (b.height || 0) - (a.height || 0)) 67 | 68 | return sorted.map(transformFormat) 69 | } 70 | 71 | export const getInfo = async (source: string): Promise => { 72 | const { videoDetails } = await fetchVideo(source) 73 | return videoDetails 74 | } 75 | 76 | interface SeparateStreams { 77 | audio: MediaRawSource[] 78 | video: MediaRawSource[] 79 | } 80 | 81 | export const getSeparateStreams = async (source: string): Promise => { 82 | const { formats } = await fetchVideo(source) 83 | 84 | const audios = formats.filter(f => f.hasAudio && !f.hasVideo) 85 | const audio = audios.sort((a, b) => (b.audioBitrate || 0) - (a.audioBitrate || 0)) 86 | 87 | const videos = formats.filter(f => !f.hasAudio && f.hasVideo) 88 | const video = videos.sort((a, b) => (b.height || 0) - (a.height || 0)) 89 | 90 | return { 91 | audio: audio.map(transformFormat), 92 | video: video.map(transformFormat), 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 36 | 37 | 38 | 39 | 55 | 56 | resync 57 | 58 | 59 | 63 | 69 |
70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-present vaaski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run front:build" 3 | publish = "dist" 4 | functions = "netlify" 5 | 6 | [build.environment] 7 | NODE_VERSION = "16" 8 | 9 | [[redirects]] 10 | from = "/todo" 11 | to = "https://github.com/resync-tv/resync/projects/2?fullscreen=true" 12 | status = 301 13 | 14 | [[redirects]] 15 | from = "/git" 16 | to = "https://github.com/resync-tv/resync" 17 | status = 301 18 | 19 | [[redirects]] 20 | from = "/*" 21 | to = "/index.html" 22 | status = 200 -------------------------------------------------------------------------------- /netlify/deploy-failed.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 3 | 4 | const { send } = require("./deploy-succeeded") 5 | 6 | const deployError = ({ error_message }) => `🛑 deploy error:\n${error_message}` 7 | 8 | exports.handler = async function (event) { 9 | const { payload } = JSON.parse(event.body) 10 | 11 | await send(deployError(payload)) 12 | 13 | return { statusCode: 200 } 14 | } 15 | -------------------------------------------------------------------------------- /netlify/deploy-succeeded.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 3 | const got = require("got") 4 | 5 | const token = process.env.TELEGRAM_TOKEN 6 | const chat_id = process.env.TELEGRAM_TO 7 | const telegram = m => `https://api.telegram.org/bot${token}/${m}` 8 | 9 | const formatDuration = ms => { 10 | if (ms < 0) ms = -ms 11 | const time = { 12 | d: Math.floor(ms / 86400000), 13 | h: Math.floor(ms / 3600000) % 24, 14 | m: Math.floor(ms / 60000) % 60, 15 | s: Math.floor(ms / 1000) % 60, 16 | ms: Math.floor(ms) % 1000, 17 | } 18 | return Object.entries(time) 19 | .filter(val => val[1] !== 0) 20 | .map(([key, val]) => `${val}${key}`) 21 | .join(", ") 22 | } 23 | 24 | const deployMessage = payload => { 25 | const { 26 | deploy_time, 27 | links: { permalink }, 28 | } = payload 29 | 30 | return ( 31 | `🔄 successfully published new version ` + 32 | `in ${formatDuration(deploy_time * 1e3)}.` 33 | ) 34 | } 35 | 36 | const send = async text => { 37 | const url = new URL(telegram("sendMessage")) 38 | 39 | const searchParams = { 40 | text, 41 | chat_id, 42 | disable_web_page_preview: true, 43 | parse_mode: "HTML", 44 | disable_notification: true, 45 | } 46 | 47 | Object.entries(searchParams).forEach(p => { 48 | url.searchParams.set(...p) 49 | }) 50 | 51 | await got(url.toString(), { method: "POST" }) 52 | } 53 | 54 | const handler = async event => { 55 | const { payload } = JSON.parse(event.body) 56 | console.log(token, chat_id, deployMessage(payload)) 57 | 58 | await send(deployMessage(payload)) 59 | 60 | return { statusCode: 200 } 61 | } 62 | 63 | exports.formatDuration = formatDuration 64 | exports.deployMessage = deployMessage 65 | exports.send = send 66 | exports.handler = handler 67 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["backend", "types"], 3 | "ext": "ts, json", 4 | "exec": "npx ts-node -P tsconfig.backend.json -r dotenv/config ./backend/index.ts", 5 | "events": { 6 | "start": "node -e \"console.clear()\"" 7 | }, 8 | "env": { 9 | "NODE_ENV": "development", 10 | "DEBUG": "resync*" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "resync", 3 | "description": "Watch YouTube videos with your friends.", 4 | "private": true, 5 | "keywords": [ 6 | "youtube", 7 | "watch", 8 | "together", 9 | "sync" 10 | ], 11 | "version": "0.0.2", 12 | "main": "lib/index.js", 13 | "license": "MIT", 14 | "author": "vaaski ", 15 | "scripts": { 16 | "start": "node -r dotenv/config lib", 17 | "back:build": "rimraf lib && tsc -p tsconfig.backend.json", 18 | "back:dev": "nodemon", 19 | "front:dev": "vite --port 8080", 20 | "front:build": "vite build", 21 | "front:serve": "vite preview", 22 | "commit": "cz -S", 23 | "format": "prettier -w **/*.{vue,ts}", 24 | "lint": "eslint --fix **/*.{vue,ts}", 25 | "types": "vue-tsc --noEmit", 26 | "quality": "npm run format & npm run lint & npm run types" 27 | }, 28 | "files": [ 29 | "lib/**/*" 30 | ], 31 | "dependencies": { 32 | "@distube/ytdl-core": "^4.16.0", 33 | "@resync-tv/yt-dl": "^0.1.6", 34 | "@sentry/browser": "^6.7.2", 35 | "@sentry/node": "^6.7.2", 36 | "@sentry/tracing": "^6.7.2", 37 | "debug": "^4.3.1", 38 | "nanoid": "^3.1.32", 39 | "nanoid-dictionary": "^4.3.0", 40 | "socket.io": "^4.1.2", 41 | "socket.io-client": "^4.1.2", 42 | "ts-debounce": "^3.0.0", 43 | "vue": "^3.2.31", 44 | "vue-router": "^4.0.14", 45 | "ytsr": "^3.5.0" 46 | }, 47 | "devDependencies": { 48 | "@types/debug": "^4.1.6", 49 | "@types/nanoid-dictionary": "^4.2.0", 50 | "@types/node": "^15.12.4", 51 | "@typescript-eslint/eslint-plugin": "^5.9.0", 52 | "@typescript-eslint/parser": "^5.9.0", 53 | "@vitejs/plugin-vue": "^1.2.4", 54 | "@vue/compiler-sfc": "^3.1.4", 55 | "@vuedx/typescript-plugin-vue": "^0.7.4", 56 | "dotenv": "^10.0.0", 57 | "eslint": "^8.4.1", 58 | "eslint-config-prettier": "^8.3.0", 59 | "eslint-plugin-prettier": "^3.4.0", 60 | "eslint-plugin-vue": "^8.5.0", 61 | "got": "^11.8.2", 62 | "nodemon": "^2.0.9", 63 | "prettier": "^2.3.2", 64 | "rimraf": "^3.0.2", 65 | "sass": "^1.35.1", 66 | "supports-color": "^9.0.1", 67 | "ts-node": "^10.0.0", 68 | "typescript": "^4.7.4", 69 | "vite": "^2.7.2", 70 | "vite-plugin-pwa": "^0.8.1", 71 | "vite-plugin-svg-icons": "^2.0.1", 72 | "vite-plugin-windicss": "^1.1.1", 73 | "vue-eslint-parser": "^8.3.0", 74 | "vue-tsc": "^0.33.5" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /pm2-staging.json: -------------------------------------------------------------------------------- 1 | { 2 | "script": "./lib/index.js", 3 | "name": "resync-staging", 4 | "node_args": "-r dotenv/config", 5 | "env": { 6 | "NODE_ENV": "staging" 7 | } 8 | } -------------------------------------------------------------------------------- /pm2.json: -------------------------------------------------------------------------------- 1 | { 2 | "script": "./lib/index.js", 3 | "name": "resync", 4 | "node_args": "-r dotenv/config" 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon-dark.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resync-tv/resync/38f26e65267fb9264492c86f9a4b80bab1be1a20/public/favicon-dark.ico -------------------------------------------------------------------------------- /public/favicon-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resync-tv/resync/38f26e65267fb9264492c86f9a4b80bab1be1a20/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /public/thumbnail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 57 | 58 | 170 | -------------------------------------------------------------------------------- /src/assets/fonts/Manrope.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resync-tv/resync/38f26e65267fb9264492c86f9a4b80bab1be1a20/src/assets/fonts/Manrope.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/RobotoMonoTimestamp.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resync-tv/resync/38f26e65267fb9264492c86f9a4b80bab1be1a20/src/assets/fonts/RobotoMonoTimestamp.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "RobotoMonoTimestamp"; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: url("./RobotoMonoTimestamp.woff2") format("woff2"); 6 | } 7 | 8 | @font-face { 9 | font-family: "ManropeVariable"; 10 | font-style: normal; 11 | font-weight: 200 800; 12 | font-display: swap; 13 | src: url("./Manrope.woff2") format("woff2"); 14 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, 15 | U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 16 | } 17 | -------------------------------------------------------------------------------- /src/assets/icons/cached.svg: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /src/assets/icons/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/content_copy.svg: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/assets/icons/content_paste.svg: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /src/assets/icons/content_paste_time.svg: -------------------------------------------------------------------------------- 1 | 8 | 13 | 18 | 19 | -------------------------------------------------------------------------------- /src/assets/icons/fullscreen.svg: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /src/assets/icons/fullscreen_exit.svg: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /src/assets/icons/open_in_new.svg: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /src/assets/icons/pause.svg: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /src/assets/icons/play_arrow.svg: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /src/assets/icons/playlist.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/skip_next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/volume_down.svg: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /src/assets/icons/volume_mute.svg: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /src/assets/icons/volume_off.svg: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /src/assets/icons/volume_up.svg: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /src/assets/theme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --clr-dark: #0c151d; 3 | --clr-md-dark: #0c151d85; 4 | --clr-light: #fcfcfc; 5 | --clr-md-light: #fcfcfc85; 6 | --clr-accent: #0d7fe2; 7 | --clr-error: #e71d67; 8 | --nav-height: 56px; 9 | --ease-in-out-hard: cubic-bezier(0.76, 0, 0.24, 1); 10 | } 11 | -------------------------------------------------------------------------------- /src/components/LoadingSpinner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 44 | -------------------------------------------------------------------------------- /src/components/NavBar.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 51 | 52 | 58 | -------------------------------------------------------------------------------- /src/components/PlayerControls.vue: -------------------------------------------------------------------------------- 1 | 119 | 120 | 181 | 182 | 210 | -------------------------------------------------------------------------------- /src/components/PlayerWrapper.vue: -------------------------------------------------------------------------------- 1 | 186 | 187 | 305 | 306 | 419 | -------------------------------------------------------------------------------- /src/components/ResyncInput.ts: -------------------------------------------------------------------------------- 1 | import { computed, defineComponent, h, onMounted, ref, toRefs } from "vue" 2 | 3 | export default defineComponent({ 4 | name: "ResyncInput", 5 | props: { 6 | invalid: { 7 | type: Boolean, 8 | default: false, 9 | }, 10 | placeholder: { 11 | type: String, 12 | default: "", 13 | }, 14 | modelValue: { 15 | type: String, 16 | default: "", 17 | }, 18 | pastable: { 19 | type: Boolean, 20 | default: false, 21 | }, 22 | autofocus: { 23 | type: Boolean, 24 | default: false, 25 | }, 26 | }, 27 | emits: ["update:modelValue"], 28 | setup(props, { emit }) { 29 | const { invalid, placeholder, modelValue } = toRefs(props) 30 | 31 | const classList = computed(() => { 32 | const base = ["resync-input"] 33 | if (invalid.value) base.push("invalid") 34 | return base 35 | }) 36 | 37 | const onContextmenu = async (event: MouseEvent) => { 38 | if (!props.pastable) return 39 | if (!navigator.clipboard.readText) return 40 | 41 | event.preventDefault() 42 | const value = await navigator.clipboard.readText() 43 | emit("update:modelValue", value) 44 | } 45 | 46 | const onKeydown = (event: KeyboardEvent) => { 47 | const target = event.target as HTMLElement 48 | if (event.key === "Escape") return target?.blur?.() 49 | } 50 | 51 | const el = ref(null) 52 | onMounted(() => { 53 | if (!el.value) throw new Error("input ref is null") 54 | if (props.autofocus) { 55 | console.log("autofocus", el.value) 56 | el.value.focus() 57 | } 58 | }) 59 | 60 | return () => 61 | h("input", { 62 | onContextmenu, 63 | onKeydown, 64 | ref: el, 65 | onInput: (event: any) => emit("update:modelValue", event.target.value), 66 | value: modelValue.value, 67 | class: classList.value, 68 | placeholder: placeholder.value, 69 | type: "text", 70 | spellcheck: false, 71 | autocomplete: "off", 72 | autocorrect: "off", 73 | autocapitalize: "off", 74 | }) 75 | }, 76 | }) 77 | -------------------------------------------------------------------------------- /src/components/ResyncLogo.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h } from "vue" 2 | 3 | export default defineComponent({ 4 | name: "ResyncLogo", 5 | setup() { 6 | return () => 7 | h( 8 | "svg", 9 | { 10 | viewBox: "0 0 512 188", 11 | xmlns: "http://www.w3.org/2000/svg", 12 | }, 13 | [ 14 | h("path", { 15 | d: "M95.297 65.784c-10.1 0-18.637 3.607-25.851 10.701-7.094 7.094-10.581 15.631-10.581 25.731v25.371c0 1.683.48 3.006 1.683 4.088 1.082 1.203 2.405 1.684 4.088 1.684 1.563 0 2.886-.481 3.968-1.684 1.203-1.082 1.804-2.405 1.804-4.088v-25.371c0-6.853 2.405-12.745 7.214-17.555 4.93-4.93 10.822-7.334 17.675-7.334 1.564 0 2.886-.602 4.089-1.684 1.082-1.202 1.683-2.525 1.683-4.088a5.725 5.725 0 00-1.683-4.088c-1.203-1.082-2.525-1.683-4.088-1.683zM140.66 133.84c7.936 0 14.91-2.405 21.042-7.455 1.323-.962 1.924-2.285 2.164-3.848.121-1.683-.24-3.006-1.322-4.329-.962-1.202-2.285-1.924-3.848-2.044-1.563-.12-3.006.241-4.208 1.323-4.089 3.246-8.658 4.809-13.828 4.809-6.012 0-11.182-2.044-15.391-6.372-4.328-4.209-6.372-9.379-6.372-15.391 0-6.012 2.044-11.182 6.372-15.51 4.209-4.21 9.379-6.374 15.391-6.374 4.93 0 9.379 1.564 13.347 4.57 3.847 3.006 6.372 6.853 7.695 11.543H140.66a5.725 5.725 0 00-4.088 1.683 5.727 5.727 0 00-1.684 4.088c0 1.563.602 3.006 1.684 4.088a5.727 5.727 0 004.088 1.684h27.655c1.563 0 2.886-.602 3.968-1.684 1.203-1.082 1.804-2.525 1.804-4.088 0-9.258-3.367-17.074-9.86-23.567-6.493-6.493-14.309-9.86-23.567-9.86-9.258 0-17.074 3.367-23.567 9.86-6.493 6.493-9.739 14.309-9.739 23.567 0 9.259 3.246 17.074 9.739 23.567 6.493 6.493 14.309 9.74 23.567 9.74zM224.118 69.631c-4.088-1.683-8.778-2.525-13.948-2.525-4.329 0-8.297.722-12.024 2.044-3.848 1.443-6.854 3.487-9.138 6.133-2.405 2.765-3.487 6.132-3.487 9.98 0 9.739 7.695 16.112 23.086 18.997 5.892 1.203 10.1 2.526 12.625 4.209 2.405 1.683 3.727 3.727 3.727 6.132 0 2.645-1.322 4.689-3.847 6.132-2.525 1.563-6.012 2.285-10.581 2.285-3.487 0-6.734-.601-9.74-1.804-3.126-1.202-5.411-2.765-6.854-4.449-1.442-1.442-3.006-2.164-4.569-2.164-1.322 0-2.765.601-4.208 1.683-1.683 1.203-2.405 2.766-2.405 4.69 0 1.443.481 2.765 1.563 3.727 2.646 2.766 6.493 4.93 11.303 6.613 4.809 1.684 9.86 2.526 15.391 2.526 5.41 0 10.1-.842 13.947-2.526 3.848-1.683 6.854-4.088 8.898-7.094 1.924-3.006 3.006-6.372 3.006-10.1 0-4.93-1.924-9.018-5.531-12.144-3.607-3.126-9.86-5.531-18.517-7.335-4.329-.841-7.575-1.683-9.739-2.645-2.285-.962-3.848-1.924-4.569-3.006-.842-1.082-1.203-2.405-1.203-4.088 0-2.165 1.082-3.968 3.487-5.17 2.405-1.203 5.411-1.924 9.138-1.924 3.127 0 5.892.48 8.177 1.202 2.164.721 4.328 2.044 6.372 3.848 1.684 1.563 3.487 2.284 5.652 2.284 1.322 0 2.404-.24 3.366-.962 1.323-1.082 2.044-2.284 2.044-3.727 0-1.202-.601-2.525-1.563-3.848-2.404-2.886-5.771-5.29-9.859-6.974zM306.358 72.878c0-2.165-1.322-3.728-3.727-4.69-.962-.36-1.924-.6-2.886-.6-2.164 0-3.727 1.202-4.689 3.486l-19.359 43.647-22.004-43.767c-1.202-2.164-2.886-3.367-5.05-3.367-.962 0-1.683.24-2.405.481a6.137 6.137 0 00-2.525 2.044 4.624 4.624 0 00-.962 2.886c0 .962.12 1.804.601 2.525l27.054 51.343-12.024 27.054c-.481.962-.721 1.923-.721 2.885 0 2.165 1.202 3.728 3.607 4.69 1.082.481 2.044.721 2.886.721 2.164 0 3.727-1.202 4.689-3.727l36.794-82.725c.481-1.083.721-2.044.721-2.886zM372.618 133.719c1.683 0 3.126-.601 4.329-1.803 1.202-1.203 1.683-2.646 1.683-4.329V96.685c0-8.416-3.006-15.63-8.898-21.643-6.012-6.012-13.346-9.018-21.763-9.018-8.537 0-15.752 3.006-21.764 9.018-6.012 6.012-9.018 13.227-9.018 21.643v30.902c0 1.683.601 3.126 1.804 4.329 1.202 1.202 2.645 1.683 4.208 1.683 1.684 0 3.127-.481 4.329-1.683 1.202-1.203 1.683-2.646 1.683-4.329V96.685c0-5.05 1.924-9.499 5.531-13.106 3.608-3.727 8.056-5.53 13.227-5.53 5.17 0 9.499 1.803 13.226 5.53 3.607 3.607 5.411 8.056 5.411 13.106v31.022c0 1.563.601 3.006 1.803 4.209 1.203 1.202 2.646 1.803 4.209 1.803zM451.933 77.928c-6.252-7.215-14.188-10.822-23.567-10.822-6.252 0-11.904 1.443-16.834 4.329-5.05 2.886-8.897 6.854-11.663 11.904-2.765 5.05-4.088 10.821-4.088 17.194 0 6.253 1.323 12.024 4.208 17.074 2.886 5.05 6.854 9.018 11.904 11.904 5.05 2.886 10.702 4.329 17.074 4.329 8.658 0 15.872-2.646 21.643-8.057.962-.961 1.443-2.044 1.443-3.246 0-1.563-.841-3.006-2.284-4.329-.962-.721-2.044-1.082-3.126-1.082-1.443 0-3.006.601-4.449 1.684-3.367 2.885-7.816 4.208-13.227 4.208-4.208 0-7.936-.962-11.182-2.886-3.246-1.924-5.772-4.569-7.575-7.936-1.804-3.366-2.645-7.334-2.645-11.663 0-6.733 1.923-12.264 5.771-16.353 3.848-4.088 8.778-6.252 15.03-6.252 3.006 0 5.651.601 8.056 1.563 2.405.962 4.569 2.525 6.613 4.69 1.203 1.442 2.766 2.164 4.69 2.164.962 0 1.924-.24 2.765-.842 1.684-1.202 2.645-2.645 2.645-4.449 0-1.202-.48-2.164-1.202-3.126z", 16 | class: "transition-colors max-h-full", 17 | }), 18 | ] 19 | ) 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /src/components/ResyncSlider.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 89 | 90 | 115 | 116 | 200 | -------------------------------------------------------------------------------- /src/components/SvgIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 32 | 33 | 40 | -------------------------------------------------------------------------------- /src/components/VideoList.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 63 | 64 | 120 | -------------------------------------------------------------------------------- /src/components/VideoPlayer.ts: -------------------------------------------------------------------------------- 1 | import type { VideoMetadata } from "/$/room" 2 | 3 | import type { SocketOff } from "/@/resync" 4 | import type Resync from "/@/resync" 5 | import shortcuts from "/@/shortcuts" 6 | import { bufferedStub, debug } from "/@/util" 7 | 8 | import { 9 | computed, 10 | defineComponent, 11 | h, 12 | inject, 13 | nextTick, 14 | onBeforeUnmount, 15 | onMounted, 16 | ref, 17 | watch, 18 | } from "vue" 19 | 20 | const log = debug("videoplayer") 21 | const logRemote = log.extend("remote") 22 | const logLocal = log.extend("local") 23 | 24 | export default defineComponent({ 25 | name: "VideoPlayer", 26 | emits: ["metadata"], 27 | setup(_, { emit }) { 28 | const resync = inject("resync") 29 | if (!resync) throw new Error("resync injection failed") 30 | 31 | const src = computed(() => resync.state.value.source?.video?.[0]?.url) 32 | const video = ref(null) 33 | const muted = ref(false) 34 | const autoplay = ref(false) 35 | 36 | const requireUserInteraction = inject<() => Promise>("requireUserInteraction") 37 | if (!requireUserInteraction) throw new Error("requireUserInteraction injection failed") 38 | 39 | const offHandlers: SocketOff[] = [] 40 | 41 | onMounted(async () => { 42 | if (!video.value) throw new Error("video ref is null") 43 | resync.currentTime = () => video.value?.currentTime ?? NaN 44 | resync.duration = () => video.value?.duration ?? NaN 45 | resync.buffered = () => video.value?.buffered ?? bufferedStub 46 | 47 | video.value.volume = resync.muted.value ? 0 : resync.volume.value 48 | 49 | if (resync.state.value.paused) { 50 | autoplay.value = false 51 | video.value.currentTime = resync.state.value.lastSeekedTo 52 | } else { 53 | autoplay.value = true 54 | resync.resync() 55 | } 56 | 57 | const play = async () => { 58 | logRemote("onResume") 59 | autoplay.value = true 60 | muted.value = false 61 | await nextTick() 62 | video.value?.play().catch(onPlaybackError) 63 | } 64 | 65 | const onPlaybackError = async (err: DOMException): Promise => { 66 | const error = log.extend("error") 67 | error(`${err.name}: ${err.message}`) 68 | 69 | if (!muted.value) { 70 | error("trying to play the video muted") 71 | 72 | muted.value = true 73 | await nextTick() 74 | return video.value 75 | ?.play() 76 | .catch(onPlaybackError) 77 | .then(async () => { 78 | error("muted video played successfully") 79 | if (["NotAllowedError"].includes(err.name)) { 80 | await requireUserInteraction() 81 | muted.value = false 82 | } 83 | }) 84 | } 85 | 86 | error("playback still failed when muted") 87 | const [reason] = err.message.split(". ") 88 | const { name } = err 89 | resync.playbackError({ reason, name }, resync.currentTime()) 90 | } 91 | 92 | let offShortcuts: () => void 93 | if (src.value) offShortcuts = shortcuts(resync) 94 | const offShortcutsRef = () => offShortcuts() 95 | 96 | offHandlers.push( 97 | resync.onPause(() => { 98 | logRemote("onPause") 99 | autoplay.value = false 100 | video.value?.pause() 101 | }), 102 | resync.onResume(play), 103 | resync.onSeekTo(seconds => { 104 | logRemote(`onSeekTo: ${seconds}`) 105 | if (!video.value) throw new Error("video ref is null (at onSeekTo)") 106 | 107 | video.value.currentTime = seconds 108 | }), 109 | resync.onRequestTime(callback => { 110 | logRemote(`onRequestTime`) 111 | callback(resync.currentTime()) 112 | }), 113 | resync.onSource(() => { 114 | if (!video.value) throw new Error("video ref is null") 115 | autoplay.value = false 116 | 117 | video.value.oncanplaythrough = () => { 118 | resync.loaded() 119 | 120 | if (!video.value) throw new Error("video ref is null") 121 | video.value.oncanplaythrough = null 122 | } 123 | }), 124 | watch(resync.volume, volume => { 125 | video.value && (video.value.volume = volume) 126 | }), 127 | watch(resync.muted, muted => { 128 | video.value && (video.value.volume = muted ? 0 : resync.volume.value) 129 | }), 130 | watch(src, () => { 131 | log(src.value) 132 | 133 | if (src.value) offShortcuts = shortcuts(resync) 134 | else offShortcutsRef() 135 | }), 136 | offShortcutsRef 137 | ) 138 | 139 | video.value.onpause = () => { 140 | resync.paused.value = true 141 | logLocal(`paused: ${resync.paused.value}`) 142 | } 143 | video.value.onplay = () => { 144 | resync.paused.value = false 145 | logLocal(`paused: ${resync.paused.value}`) 146 | } 147 | video.value.onended = () => { 148 | logLocal(`ended`) 149 | resync.finished() 150 | resync.paused.value = true 151 | } 152 | 153 | video.value.onloadedmetadata = () => { 154 | if (!video.value) throw new Error("video ref is null") 155 | const { videoHeight, videoWidth } = video.value 156 | const metadata: VideoMetadata = { videoHeight, videoWidth } 157 | 158 | emit("metadata", metadata) 159 | } 160 | 161 | video.value.onclick = () => { 162 | resync.paused.value ? resync.resume() : resync.pause(resync.currentTime()) 163 | } 164 | video.value.oncontextmenu = e => e.preventDefault() 165 | }) 166 | 167 | onBeforeUnmount(() => offHandlers.forEach(off => off())) 168 | 169 | return () => { 170 | return h("video", { 171 | src: src.value, 172 | ref: video, 173 | disablePictureInPicture: true, 174 | preload: "auto", 175 | muted: muted.value, 176 | autoplay: autoplay.value, 177 | }) 178 | } 179 | }, 180 | }) 181 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as sentry from "@sentry/browser" 2 | import { Integrations } from "@sentry/tracing" 3 | 4 | if (process.env.NODE_ENV !== "development") { 5 | sentry.init({ 6 | dsn: "https://5b4d331966544c5e823e1ea81f56e3cf@o105856.ingest.sentry.io/5712866", 7 | integrations: [new Integrations.BrowserTracing()], 8 | tracesSampleRate: 1.0, 9 | }) 10 | } 11 | 12 | const log = debug("main") 13 | 14 | import type { MediaMetadata } from "/$/MediaSession" 15 | 16 | import { createApp } from "vue" 17 | import App from "/@/App.vue" 18 | import router from "./router" 19 | 20 | import "/@/assets/theme.css" 21 | import "/@/assets/fonts/fonts.css" 22 | 23 | import "virtual:windi.css" 24 | import "virtual:svg-icons-register" 25 | 26 | import { registerSW } from "virtual:pwa-register" 27 | import { debug } from "./util" 28 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 29 | const updateSW = registerSW({ 30 | onOfflineReady: () => log("offline ready"), 31 | }) 32 | 33 | declare global { 34 | //? seems to be in typescript itself now 35 | // interface Navigator { 36 | // mediaSession?: MediaSession 37 | // } 38 | interface Window { 39 | MediaMetadata?: typeof MediaMetadata 40 | } 41 | } 42 | 43 | const app = createApp(App) 44 | app.use(router).mount("#app") 45 | 46 | app.config.errorHandler = (error, _, info) => { 47 | sentry.setTag("info", info) 48 | sentry.captureException(error) 49 | } 50 | -------------------------------------------------------------------------------- /src/mediaSession.ts: -------------------------------------------------------------------------------- 1 | import type { MediaImage } from "/$/MediaSession" 2 | import type { MediaSourceAny } from "/$/mediaSource" 3 | 4 | export const setMetadata = (data: MediaSourceAny, room?: string): void => { 5 | if (!window.navigator.mediaSession || !window.MediaMetadata) return 6 | 7 | const artwork: MediaImage[] = [] 8 | if (data.thumb) artwork.push({ src: data.thumb }) 9 | 10 | window.navigator.mediaSession.metadata = new window.MediaMetadata({ 11 | title: data.title, 12 | artist: data.uploader, 13 | album: room ?? "resync.tv", 14 | artwork, 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /src/notify.ts: -------------------------------------------------------------------------------- 1 | import type { EventNotification, NotifyEvents } from "/$/room" 2 | import { timestamp } from "./util" 3 | 4 | type RenderNotification = { 5 | [k in NotifyEvents]: (n: EventNotification) => string 6 | } 7 | 8 | export const renderNotification: RenderNotification = { 9 | join: n => `${n.name} joined the room`, 10 | leave: n => `${n.name} left the room`, 11 | playContent: n => { 12 | if (n.additional.source) return `${n.name} changed the playing video` 13 | return `${n.name} stopped playback` 14 | }, 15 | pause: n => `${n.name} paused`, 16 | resume: n => `${n.name} resumed`, 17 | seekTo: n => `${n.name} skipped to ${timestamp(n.additional.seconds)}`, 18 | resync: n => `${n.name} resync'd the room`, 19 | playbackError: n => `${n.name} encountered a playback error`, 20 | queue: n => `${n.name} queued a video`, 21 | removeQueued: n => `${n.name} removed a queued video`, 22 | clearQueue: n => `${n.name} cleared the queue`, 23 | } 24 | -------------------------------------------------------------------------------- /src/resync.ts: -------------------------------------------------------------------------------- 1 | import type { Socket } from "socket.io-client" 2 | import type { RoomState } from "/$/room" 3 | import type { BackendEmits, ResyncSocketFrontend, RoomEmit } from "/$/socket" 4 | 5 | import type { Ref } from "vue" 6 | import { ref, watch } from "vue" 7 | import { bufferedStub, capitalize, debug, ls } from "./util" 8 | import { setMetadata } from "./mediaSession" 9 | import type { MediaSourceAny } from "/$/mediaSource" 10 | 11 | const log = debug("resync.ts") 12 | 13 | export type SocketOff = () => void 14 | 15 | export default class Resync { 16 | private socket: ResyncSocketFrontend 17 | private roomEmit: RoomEmit 18 | private roomID: string 19 | private handlers: SocketOff[] = [] 20 | currentTime = (): number => NaN 21 | duration = (): number => NaN 22 | buffered = (): HTMLMediaElement["buffered"] => bufferedStub 23 | 24 | fullscreenEnabled: Ref | undefined 25 | paused = ref(true) 26 | volume = ref(ls("resync-volume") ?? 0.5) 27 | muted = ref(ls("resync-muted") ?? false) 28 | state: Ref 29 | 30 | constructor(socket: Socket, roomID: string) { 31 | this.socket = socket 32 | this.roomID = roomID 33 | this.roomEmit = (event, arg, ...args) => { 34 | log.extend("roomEmit")(event, { roomID, ...arg }, ...args) 35 | socket.emit(event, { roomID, ...arg }, ...args) 36 | } 37 | 38 | this.state = ref({ 39 | paused: this.paused.value, 40 | source: undefined, 41 | lastSeekedTo: 0, 42 | members: [], 43 | membersLoading: 0, 44 | queue: [], 45 | }) 46 | 47 | this.handlers.push( 48 | watch(this.volume, volume => { 49 | ls("resync-volume", volume) 50 | }), 51 | watch(this.muted, muted => { 52 | ls("resync-muted", muted) 53 | }), 54 | this.onState(state => { 55 | log("new state", state) 56 | this.state.value = state 57 | }), 58 | this.onSource(this.updateMediasession) 59 | ) 60 | } 61 | destroy = (): void => this.handlers.forEach(off => off()) 62 | 63 | private eventHandler(event: E) { 64 | return (fn: BackendEmits[E]): SocketOff => { 65 | // @ts-expect-error I am clueless as to why this errors 66 | this.socket.on(event, fn) 67 | log(`registered on${capitalize(event)} handler`) 68 | 69 | return () => { 70 | // @ts-expect-error I am clueless as to why this errors 71 | this.socket.off(event, fn) 72 | log(`unregistered on${capitalize(event)} handler`) 73 | } 74 | } 75 | } 76 | 77 | private updateMediasession = (source?: MediaSourceAny) => { 78 | if (source) setMetadata(source, `room: ${this.roomID}`) 79 | } 80 | 81 | static getNewRandom = (socket: ResyncSocketFrontend): Promise => { 82 | return new Promise(res => { 83 | socket.emit("getNewRandom", res) 84 | }) 85 | } 86 | 87 | search = (query: string): Promise => { 88 | return new Promise(res => this.socket.emit("search", query, res)) 89 | } 90 | 91 | joinRoom = async (name: string): Promise => { 92 | const join = () => { 93 | return new Promise(res => { 94 | this.roomEmit("joinRoom", { name }, state => { 95 | log("initial room state", state) 96 | 97 | this.state.value = state 98 | this.updateMediasession(state.source) 99 | 100 | res() 101 | }) 102 | }) 103 | } 104 | 105 | const connect = () => { 106 | this.socket.off("connect", connect) 107 | join() 108 | } 109 | 110 | const disconnect = () => { 111 | this.socket.on("connect", connect) 112 | } 113 | 114 | this.socket.on("disconnect", disconnect) 115 | 116 | this.handlers.push(() => this.socket.off("disconnect", disconnect)) 117 | this.handlers.push(() => this.roomEmit("leaveRoom")) 118 | 119 | await join() 120 | } 121 | 122 | playContent = (source: string): void => this.roomEmit("playContent", { source }) 123 | queue = (source: string): void => this.roomEmit("queue", { source }) 124 | playQueued = (index: number): void => this.roomEmit("playQueued", { index }) 125 | clearQueue = (): void => this.roomEmit("clearQueue") 126 | removeQueued = (index: number): void => this.roomEmit("removeQueued", { index }) 127 | loaded = (): void => this.roomEmit("loaded") 128 | finished = (): void => this.roomEmit("finished") 129 | pause = (currentTime: number): void => this.roomEmit("pause", { currentTime }) 130 | resume = (): void => this.roomEmit("resume") 131 | seekTo = (currentTime: number): void => this.roomEmit("seekTo", { currentTime }) 132 | resync = (): void => this.roomEmit("resync") 133 | message = (msg: string): void => this.roomEmit("message", { msg }) 134 | 135 | playbackError = (error: { reason: string; name: string }, currentTime: number): void => { 136 | this.roomEmit("playbackError", { ...error, currentTime }) 137 | } 138 | onSource = this.eventHandler("source") 139 | onPause = this.eventHandler("pause") 140 | onResume = this.eventHandler("resume") 141 | onSeekTo = this.eventHandler("seekTo") 142 | onRequestTime = this.eventHandler("requestTime") 143 | onNotify = this.eventHandler("notifiy") 144 | onState = this.eventHandler("state") 145 | onMessage = this.eventHandler("message") 146 | } 147 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import type { RouteRecordRaw } from "vue-router" 2 | import { createRouter, createWebHistory } from "vue-router" 3 | 4 | const routes: Array = [ 5 | { 6 | path: "/", 7 | name: "home", 8 | component: () => import("./views/ResyncHome.vue"), 9 | }, 10 | { 11 | path: "/signup", 12 | name: "signup", 13 | component: () => import("./views/ResyncSignup.vue"), 14 | }, 15 | { 16 | path: "/r/:roomID", 17 | redirect: { name: "room" }, 18 | }, 19 | { 20 | path: "/:roomID", 21 | name: "room", 22 | component: () => import("./views/ResyncRoom.vue"), 23 | }, 24 | ] 25 | 26 | const router = createRouter({ 27 | history: createWebHistory(import.meta.env.BASE_URL), 28 | routes, 29 | }) 30 | 31 | export default router 32 | -------------------------------------------------------------------------------- /src/shortcuts.ts: -------------------------------------------------------------------------------- 1 | import type Resync from "/@/resync" 2 | import type { MediaSessionAction } from "/$/MediaSession" 3 | import { debug, minMax } from "./util" 4 | 5 | const setMediaHandler = (type: MediaSessionAction, fn: () => void) => { 6 | if (!navigator.mediaSession) return () => undefined 7 | 8 | navigator.mediaSession.setActionHandler(type, fn) 9 | return () => navigator.mediaSession?.setActionHandler(type, null) 10 | } 11 | 12 | const log = debug("shortcuts") 13 | 14 | export default (resync: Resync): (() => void) => { 15 | log("registering shortcuts") 16 | 17 | const skip = (t: number) => { 18 | const time = minMax(resync.currentTime() + t, 0, resync.duration()) 19 | resync.seekTo(time) 20 | } 21 | const pause = () => resync.pause(resync.currentTime()) 22 | const volume = (v: number) => (resync.volume.value = minMax(resync.volume.value + v)) 23 | 24 | const offMediaHandle = [ 25 | setMediaHandler("play", () => resync.resume()), 26 | setMediaHandler("pause", () => pause()), 27 | setMediaHandler("nexttrack", () => skip(5)), 28 | setMediaHandler("previoustrack", () => skip(-5)), 29 | ] 30 | 31 | document.addEventListener('fullscreenchange', () => { 32 | if (!document.fullscreenElement && resync.fullscreenEnabled) 33 | resync.fullscreenEnabled.value = !resync.fullscreenEnabled.value; 34 | }, false) 35 | 36 | window.onkeydown = (event: KeyboardEvent) => { 37 | const { key } = event 38 | 39 | if (document.activeElement instanceof HTMLInputElement) return 40 | 41 | if (key === "ArrowRight") return skip(5) 42 | if (key === "ArrowLeft") return skip(-5) 43 | if (key === "l") return skip(10) 44 | if (key === "j") return skip(-10) 45 | 46 | if (key === "Home") return resync.seekTo(0) 47 | if (key === "End") { 48 | if (resync.state.value.queue.length) return resync.playQueued(0) 49 | else resync.playContent("") 50 | } 51 | 52 | if (key === "k" || key === " ") 53 | return resync.state.value.paused ? resync.resume() : pause() 54 | 55 | if (key === "m") return (resync.muted.value = !resync.muted.value) 56 | if (key === "ArrowUp") return volume(0.05) 57 | if (key === "ArrowDown") return volume(-0.05) 58 | 59 | if (key === "q") return log("TODO: toggle queue") 60 | 61 | if (key === "f") return log("TODO: fullscreen") 62 | 63 | if (key === "P") return log("TODO: previous video") 64 | if (key === "N") return log("TODO: next video") 65 | } 66 | const offKeydown = () => { 67 | window.onkeydown = null 68 | log("unregistering shortcuts") 69 | } 70 | 71 | return () => [...offMediaHandle, offKeydown].forEach(off => off()) 72 | } 73 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import _debug from "debug" 2 | 3 | interface LocalStored { 4 | "resync-displayname": string 5 | "resync-volume": number 6 | "resync-muted": boolean 7 | "resync-last-room": string 8 | } 9 | 10 | const urlReg = 11 | //? researching this can lead to very deep rabbit holes, but i think i'd consider this good enough. 12 | /(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)/i 13 | 14 | export const isURL = (str: string): boolean => urlReg.test(str) 15 | 16 | export const ls = ( 17 | key: L, 18 | value?: LocalStored[L] 19 | ): LocalStored[L] | null => 20 | void 0 !== value 21 | ? localStorage.setItem(key, JSON.stringify(value)) 22 | : JSON.parse(localStorage.getItem(key) as string) 23 | 24 | export const timestamp = (seconds = 0): string => { 25 | const pad = (n: number) => n.toString().padStart(2, "0") 26 | 27 | if (isNaN(seconds)) seconds = 0 28 | 29 | const h = Math.floor(seconds / 3600) 30 | const m = Math.floor(seconds / 60) % 60 31 | const s = Math.floor(seconds - m * 60) % 3600 32 | 33 | const ts = `${pad(s)}` 34 | 35 | if (h) return `${h}:${pad(m)}:${ts}` 36 | else return `${m}:${ts}` 37 | } 38 | 39 | export const capitalize = (str: string): string => [...str][0].toUpperCase() + str.slice(1) 40 | export const once = ( 41 | fn: (this: T, ...arg: A) => R 42 | ): ((this: T, ...arg: A) => R | undefined) => { 43 | let done = false 44 | return function (this: T, ...args: A) { 45 | return done ? void 0 : ((done = true), fn.apply(this, args)) 46 | } 47 | } 48 | 49 | export const debug = (namespace: string) => _debug("resync").extend(namespace) 50 | 51 | export const minMax = (n: number, min = 0, max = 1): number => Math.max(min, Math.min(max, n)) 52 | 53 | export const validateName = (name: string): string => { 54 | name = name.trim() 55 | 56 | if (!name) throw "please enter a name" 57 | if (!/[a-z0-9\u00F0-\u02AF]/i.test(name)) throw "name must be alphanumeric" 58 | if (name.length < 3) throw "name must be 3 or more characters" 59 | if (name.length > 16) throw "name must be less than 16 characters" 60 | 61 | return name 62 | } 63 | 64 | export const bufferedStub: any = [] 65 | bufferedStub.start = () => 0 66 | bufferedStub.end = () => 0 67 | 68 | export const bufferedArray = ( 69 | buffered: HTMLMediaElement["buffered"], 70 | duration: number 71 | ): number[][] => { 72 | const ret = [] 73 | 74 | for (let i = 0; i < buffered.length; i++) { 75 | const start = buffered.start(i) / duration 76 | const end = buffered.end(i) / duration 77 | 78 | ret.push([start, end]) 79 | } 80 | 81 | return ret 82 | } 83 | 84 | export const isStaging = () => location.hostname === "staging.resync.tv" 85 | 86 | export const unfocus = (): void => (window.document.activeElement as HTMLElement)?.blur?.() 87 | -------------------------------------------------------------------------------- /src/views/ResyncHome.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 56 | 57 | 95 | -------------------------------------------------------------------------------- /src/views/ResyncRoom.vue: -------------------------------------------------------------------------------- 1 | 158 | 159 | 270 | 271 | 334 | -------------------------------------------------------------------------------- /src/views/ResyncSignup.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 65 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite-plugin-windicss" 2 | import defaultTheme from "windicss/defaultTheme" 3 | 4 | const { sans, mono } = defaultTheme.fontFamily 5 | 6 | export default defineConfig({ 7 | darkMode: "class", 8 | theme: { 9 | fontFamily: { 10 | ...defaultTheme.fontFamily, 11 | DEFAULT: ["ManropeVariable"], 12 | sans: ["ManropeVariable", ...sans], 13 | mono: ['"Roboto Mono"', ...mono], 14 | }, 15 | extend: { 16 | colors: { 17 | dark: "var(--clr-dark)", 18 | "md-dark": "var(--clr-md-dark)", 19 | light: "var(--clr-light)", 20 | "md-light": "var(--clr-md-light)", 21 | accent: "var(--clr-accent)", 22 | error: "var(--clr-error)", 23 | }, 24 | transitionTimingFunction: { 25 | "ease-in-out-hard": "var(--ease-in-out-hard)", 26 | }, 27 | height: { 28 | nav: "var(--nav-height)", 29 | }, 30 | inset: { 31 | half: "50%", 32 | }, 33 | spacing: { 34 | half: "50%", 35 | }, 36 | }, 37 | }, 38 | plugins: [require("windicss/plugin/aspect-ratio")], 39 | }) 40 | -------------------------------------------------------------------------------- /tsconfig.backend.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "lib": ["esnext", "ES2020", "DOM", "DOM.Iterable"], 6 | "outDir": "lib", 7 | "allowJs": true, 8 | "strict": true, 9 | "noImplicitAny": true, 10 | "esModuleInterop": true, 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "declaration": true, 15 | "jsx": "preserve", 16 | "sourceMap": true, 17 | "paths": { 18 | "/@/*": ["./src/*"], 19 | "/$/*": ["./types/*"] 20 | } 21 | }, 22 | "include": ["backend", "util.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "esnext", 5 | "sourceMap": false, 6 | "moduleResolution": "Node", 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "isolatedModules": true, 10 | "allowSyntheticDefaultImports": true, 11 | "jsx": "preserve", 12 | 13 | "types": ["vite/client", "vite-plugin-pwa/client"], 14 | "plugins": [{ "name": "@vuedx/typescript-plugin-vue" }], 15 | "lib": ["ESNext", "dom", "dom.iterable"], 16 | 17 | "baseUrl": ".", 18 | "paths": { 19 | "/@/*": ["./src/*"], 20 | "/$/*": ["./types/*"] 21 | } 22 | }, 23 | "include": ["backend", "src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"] 24 | } 25 | -------------------------------------------------------------------------------- /types/MediaSession.d.ts: -------------------------------------------------------------------------------- 1 | /* spell-checker: disable */ 2 | // Type definitions for non-npm package Media Session API 1.1 3 | // Project: https://wicg.github.io/mediasession/ 4 | // Definitions by: Julien CROUZET 5 | // Eana Hufwe 6 | // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped 7 | 8 | // exported from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/57293cfddb2e48ff8c7deaa7ee3ac0101d83d014/types/wicg-mediasession/index.d.ts 9 | 10 | /* spell-checker: enable */ 11 | // modified by vaaski 12 | 13 | interface Navigator { 14 | readonly mediaSession?: MediaSession 15 | } 16 | 17 | interface Window { 18 | MediaSession?: MediaSession 19 | } 20 | 21 | type MediaSessionPlaybackState = "none" | "paused" | "playing" 22 | 23 | type MediaSessionAction = 24 | | "play" 25 | | "pause" 26 | | "seekbackward" 27 | | "seekforward" 28 | | "seekto" 29 | | "previoustrack" 30 | | "nexttrack" 31 | | "skipad" 32 | | "stop" 33 | 34 | interface SetPositionState { 35 | (playbackState?: MediaPositionState): void 36 | } 37 | 38 | type ActionHandlerListenerArg = ( 39 | details: Required> & MediaSessionActionDetails 40 | ) => void 41 | 42 | export interface MediaSession { 43 | // Current media session playback state. 44 | playbackState: MediaSessionPlaybackState 45 | // Current media session meta data. 46 | metadata: MediaMetadata | null 47 | 48 | // Set/Unset actions handlers. 49 | setActionHandler(action: "seekto", listener: ActionHandlerListenerArg | null): void 50 | setActionHandler( 51 | action: MediaSessionAction, 52 | listener: ((details: MediaSessionActionDetails) => void) | null 53 | ): void 54 | 55 | // Set/unset position state 56 | setPositionState?: SetPositionState 57 | } 58 | 59 | interface MediaImage { 60 | // URL from which the user agent can fetch the image’s data. 61 | src: string 62 | // Specify the MediaImage object’s sizes. It follows the spec of sizes attribute in HTML link element. 63 | sizes?: string 64 | // A hint as to the media type of the image. 65 | type?: string 66 | } 67 | 68 | interface MediaMetadataInit { 69 | // Media's title. 70 | title?: string 71 | // Media's artist. 72 | artist?: string 73 | // Media's album. 74 | album?: string 75 | // Media's artwork. 76 | artwork?: MediaImage[] 77 | } 78 | 79 | export declare class MediaMetadata { 80 | constructor(init?: MediaMetadataInit) 81 | // Media's title. 82 | title: string 83 | // Media's artist. 84 | artist: string 85 | // Media's album. 86 | album: string 87 | // Media's artwork. 88 | artwork: ReadonlyArray 89 | } 90 | 91 | interface MediaPositionState { 92 | // Duration of media in seconds 93 | duration?: number 94 | 95 | // Playback rate of media, positive for forward playback, negative for backward playback. This number should not be zero 96 | playbackRate?: number 97 | 98 | // Last reported playback position in seconds, should be positive. 99 | position?: number 100 | } 101 | 102 | interface MediaSessionActionDetails { 103 | // The action that the handler is associated with 104 | action: MediaSessionAction 105 | 106 | // This MAY be provided when the action is seekbackward or seekforward. Stores number of seconds to move the playback time by. 107 | seekOffset?: number 108 | 109 | // MUST be provided when action is seekto. Stores the time in seconds to move the playback time to. 110 | seekTime?: number 111 | 112 | // MAY be provided when action is seekto. Stores true if the action is being called multiple times as part of a sequence and this is not the last call in that sequence. 113 | fastSeek?: boolean 114 | } 115 | -------------------------------------------------------------------------------- /types/mediaSource.d.ts: -------------------------------------------------------------------------------- 1 | export type Platform = "youtube" | "soundcloud" | "other" 2 | 3 | export interface MediaRawSource { 4 | url: string 5 | quality: string 6 | } 7 | 8 | export interface OriginalSource { 9 | url: string 10 | youtubeID?: string 11 | } 12 | 13 | export type MediaType = "audio" | "video" | "audiovideo" 14 | 15 | export interface MediaBase { 16 | platform: Platform 17 | startFrom: number 18 | title: string 19 | uploader?: string 20 | thumb?: string 21 | duration: number 22 | type: MediaType 23 | originalSource: OriginalSource 24 | } 25 | 26 | export interface MediaAudio extends MediaBase { 27 | audio: MediaRawSource[] 28 | type: "audio" 29 | } 30 | 31 | export interface MediaVideo extends MediaBase { 32 | video: MediaRawSource[] 33 | type: "video" 34 | } 35 | 36 | export interface MediaAudioVideo extends MediaAudio, MediaVideo { 37 | type: "audiovideo" 38 | } 39 | 40 | // prettier-ignore 41 | export interface MediaSourceAny extends MediaBase, Partial, Partial, Partial {} 42 | -------------------------------------------------------------------------------- /types/room.d.ts: -------------------------------------------------------------------------------- 1 | import type { MediaSourceAny } from "./mediaSource" 2 | 3 | import type { Socket as BackendSocket } from "socket.io" 4 | 5 | export interface Member { 6 | name: string 7 | client: BackendSocket 8 | } 9 | 10 | export interface PublicMember extends Omit { 11 | id: string 12 | } 13 | 14 | export interface RoomState { 15 | paused: boolean 16 | source: S | undefined 17 | lastSeekedTo: number 18 | members: Array 19 | membersLoading: number 20 | queue: MediaSourceAny[] 21 | } 22 | 23 | export type NotifyEvents = 24 | | "join" 25 | | "leave" 26 | | "playContent" 27 | | "pause" 28 | | "resume" 29 | | "seekTo" 30 | | "resync" 31 | | "playbackError" 32 | | "queue" 33 | | "removeQueued" 34 | | "clearQueue" 35 | 36 | export interface EventNotification { 37 | event: NotifyEvents 38 | id: string 39 | key: string 40 | name: string 41 | additional?: any 42 | } 43 | 44 | export interface VideoMetadata { 45 | videoHeight: number 46 | videoWidth: number 47 | } 48 | 49 | export interface Message { 50 | name: string 51 | msg: string 52 | key: string 53 | } 54 | -------------------------------------------------------------------------------- /types/socket.d.ts: -------------------------------------------------------------------------------- 1 | import type { Server } from "socket.io" 2 | import type { Socket } from "socket.io-client" 3 | 4 | import type { EventNotification, RoomState } from "./room" 5 | import type { MediaSourceAny } from "./mediaSource" 6 | 7 | type Callback = (x: A) => void 8 | 9 | type BackendEmitterBase = (x: A) => void 10 | 11 | export interface BackendEmits { 12 | notifiy: BackendEmitterBase 13 | message: BackendEmitterBase 14 | source: BackendEmitterBase 15 | pause: BackendEmitterBase 16 | resume: BackendEmitterBase 17 | seekTo: BackendEmitterBase 18 | requestTime: BackendEmitterBase> 19 | state: BackendEmitterBase 20 | } 21 | 22 | interface RoomEmitBase { 23 | roomID: string 24 | } 25 | interface RoomEmitTime extends RoomEmitBase { 26 | currentTime: number 27 | } 28 | 29 | type FrontendEmitterBase = (x: RoomEmitBase & A, c: C) => void 30 | type FrontendEmitterTime = (x: RoomEmitTime & A, c: C) => void 31 | 32 | export interface FrontendEmits { 33 | playContent: FrontendEmitterBase<{ source: string; startFrom?: number }> 34 | playQueued: FrontendEmitterBase<{ index: number }> 35 | removeQueued: FrontendEmitterBase<{ index: number }> 36 | queue: FrontendEmitterBase<{ source: string; startFrom?: number }> 37 | clearQueue: FrontendEmitterBase 38 | loaded: FrontendEmitterBase 39 | finished: FrontendEmitterBase 40 | pause: FrontendEmitterTime 41 | resume: FrontendEmitterBase 42 | seekTo: FrontendEmitterTime 43 | resync: FrontendEmitterBase 44 | playbackError: FrontendEmitterTime<{ reason: string; name: string }> 45 | joinRoom: FrontendEmitterBase<{ name: string }, Callback> 46 | leaveRoom: FrontendEmitterBase 47 | message: FrontendEmitterBase<{ msg: string }> 48 | getNewRandom: (c: Callback) => void 49 | search: (query: string, c: Callback) => void 50 | } 51 | 52 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 53 | type Tail = T extends [infer A, ...infer R] ? R : never 54 | 55 | export type RoomEmit = ( 56 | event: E, 57 | arg?: Omit[0], "roomID">, 58 | ...args: Tail> 59 | ) => void 60 | 61 | export type ResyncSocketFrontend = Socket 62 | export type ResyncSocketBackend = Server 63 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { join, resolve } from "path" 2 | import { defineConfig } from "vite" 3 | 4 | import vue from "@vitejs/plugin-vue" 5 | import WindiCSS from "vite-plugin-windicss" 6 | import { VitePWA } from "vite-plugin-pwa" 7 | import { createSvgIconsPlugin } from "vite-plugin-svg-icons" 8 | 9 | const PACKAGE_ROOT = __dirname 10 | 11 | export default defineConfig({ 12 | root: PACKAGE_ROOT, 13 | plugins: [ 14 | vue(), 15 | WindiCSS({ 16 | scan: { 17 | fileExtensions: ["vue", "html", "ts"], 18 | }, 19 | }), 20 | createSvgIconsPlugin({ 21 | iconDirs: [resolve(process.cwd(), "src/assets/icons")], 22 | symbolId: "icon-[dir]-[name]", 23 | }), 24 | VitePWA({ 25 | registerType: "autoUpdate", 26 | }), 27 | ], 28 | resolve: { 29 | alias: { 30 | "/@/": join(PACKAGE_ROOT, "src") + "/", 31 | "/$/": join(PACKAGE_ROOT, "types") + "/", 32 | }, 33 | }, 34 | server: { 35 | fs: { 36 | strict: true, 37 | }, 38 | }, 39 | build: { 40 | sourcemap: true, 41 | emptyOutDir: true, 42 | brotliSize: false, 43 | }, 44 | }) 45 | --------------------------------------------------------------------------------