forked from lavender/watch-party
Initial commit
commit
468843c430
|
@ -0,0 +1,12 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = crlf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.rs]
|
||||||
|
indent_size = 4
|
|
@ -0,0 +1 @@
|
||||||
|
/target
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,15 @@
|
||||||
|
[package]
|
||||||
|
name = "watch-party"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
futures = "0.3.17"
|
||||||
|
futures-util = "0.3.17"
|
||||||
|
once_cell = "1.8.0"
|
||||||
|
serde = { version = "1.0.130", features = ["derive"] }
|
||||||
|
serde_json = "1.0.68"
|
||||||
|
tokio = { version = "1.12.0", features = ["full"] }
|
||||||
|
tokio-stream = "0.1.7"
|
||||||
|
uuid = { version = "0.8.2", features = ["v4"] }
|
||||||
|
warp = "0.3.1"
|
|
@ -0,0 +1,36 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>watch party :D</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
This site will <em>not</em> work without JavaScript, and there's not
|
||||||
|
really any way around that :(
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<div id="pre-join-controls">
|
||||||
|
<form id="join-session-form">
|
||||||
|
<h2>Join a session</h2>
|
||||||
|
|
||||||
|
<label for="session-id">Session ID:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="join-session-id"
|
||||||
|
placeholder="123e4567-e89b-12d3-a456-426614174000"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button>Join</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
No session to join? <a href="/create.html">Create a session</a> instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,175 @@
|
||||||
|
/**
|
||||||
|
* @param {string} videoUrl
|
||||||
|
* @param {{name: string, url: string}[]} subtitles
|
||||||
|
*/
|
||||||
|
const createVideoElement = (videoUrl, subtitles) => {
|
||||||
|
document.querySelector("#pre-join-controls").style["display"] = "none";
|
||||||
|
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.controls = true;
|
||||||
|
video.autoplay = false;
|
||||||
|
|
||||||
|
const source = document.createElement("source");
|
||||||
|
source.src = videoUrl;
|
||||||
|
|
||||||
|
video.appendChild(source);
|
||||||
|
|
||||||
|
let first = true;
|
||||||
|
for (const { name, url } of subtitles) {
|
||||||
|
const track = document.createElement("track");
|
||||||
|
track.label = name;
|
||||||
|
track.src = url;
|
||||||
|
track.kind = "captions";
|
||||||
|
|
||||||
|
if (first) {
|
||||||
|
track.default = true;
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.appendChild(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
return video;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {WebSocket} socket
|
||||||
|
* @param {HTMLVideoElement} video
|
||||||
|
*/
|
||||||
|
const setupSocketEvents = (socket, video) => {
|
||||||
|
const setVideoTime = time => {
|
||||||
|
const timeSecs = time / 1000.0;
|
||||||
|
|
||||||
|
if (Math.abs(video.currentTime - timeSecs) > 0.5) {
|
||||||
|
video.currentTime = timeSecs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.addEventListener("message", async messageEvent => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(messageEvent.data);
|
||||||
|
console.log(event);
|
||||||
|
|
||||||
|
switch (event.op) {
|
||||||
|
case "SetPlaying":
|
||||||
|
if (event.data.playing) {
|
||||||
|
await video.play();
|
||||||
|
} else {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
setVideoTime(event.data.time);
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "SetTime":
|
||||||
|
setVideoTime(event.data);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} videoUrl
|
||||||
|
* @param {{name: string, url: string}[]} subtitles
|
||||||
|
* @param {number} currentTime
|
||||||
|
* @param {boolean} playing
|
||||||
|
* @param {WebSocket} socket
|
||||||
|
*/
|
||||||
|
const setupVideo = async (sessionId, videoUrl, subtitles, currentTime, playing, socket) => {
|
||||||
|
const video = createVideoElement(videoUrl, subtitles);
|
||||||
|
document.body.appendChild(video);
|
||||||
|
|
||||||
|
video.currentTime = (currentTime / 1000.0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (playing) {
|
||||||
|
await video.play()
|
||||||
|
} else {
|
||||||
|
video.pause()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Auto-play is probably disabled, we should uhhhhhhh do something about it
|
||||||
|
}
|
||||||
|
|
||||||
|
video.addEventListener("pause", async event => {
|
||||||
|
await fetch(`/sess/${sessionId}/playing`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(false),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener("play", async event => {
|
||||||
|
await fetch(`/sess/${sessionId}/playing`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(true),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let firstSeekComplete = false;
|
||||||
|
video.addEventListener("seeked", async event => {
|
||||||
|
if (!firstSeekComplete) {
|
||||||
|
// The first seeked event is performed by the browser when the video is loading
|
||||||
|
firstSeekComplete = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch(`/sess/${sessionId}/current_time`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify((video.currentTime * 1000) | 0),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setupSocketEvents(socket, video);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} sessionId */
|
||||||
|
const joinSession = async (sessionId) => {
|
||||||
|
try {
|
||||||
|
window.location.hash = sessionId;
|
||||||
|
|
||||||
|
const {
|
||||||
|
video_url, subtitle_tracks,
|
||||||
|
current_time_ms, is_playing
|
||||||
|
} = await fetch(`/sess/${sessionId}`).then(r => r.json());
|
||||||
|
|
||||||
|
const wsUrl = new URL(`/sess/${sessionId}/subscribe`, window.location.href);
|
||||||
|
wsUrl.protocol = { "http:": "ws:", "https:": "wss:" }[wsUrl.protocol];
|
||||||
|
const socket = new WebSocket(wsUrl.toString());
|
||||||
|
|
||||||
|
setupVideo(sessionId, video_url, subtitle_tracks, current_time_ms, is_playing, socket);
|
||||||
|
} catch (err) {
|
||||||
|
// TODO: Show an error on the screen
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const main = () => {
|
||||||
|
document.querySelector("#join-session-form").addEventListener("submit", event => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const sessionId = document.querySelector("#join-session-id").value;
|
||||||
|
joinSession(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (window.location.hash.match(/#[0-9a-f\-]+/)) {
|
||||||
|
document.querySelector("#join-session-id").value = window.location.hash.substring(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === "complete") {
|
||||||
|
main();
|
||||||
|
} else {
|
||||||
|
document.addEventListener("DOMContentLoaded", main);
|
||||||
|
}
|
|
@ -0,0 +1,101 @@
|
||||||
|
:root {
|
||||||
|
--bg: rgb(28, 23, 36);
|
||||||
|
--fg: rgb(234, 234, 248);
|
||||||
|
--accent: hsl(275, 57%, 68%);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
width: 100vw;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="url"],
|
||||||
|
input[type="text"] {
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
background: #fff;
|
||||||
|
background-clip: padding-box;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(0, 0, 0, 0.8);
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 1em;
|
||||||
|
width: 500px;
|
||||||
|
|
||||||
|
resize: none;
|
||||||
|
overflow-x: wrap;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: var(--accent);
|
||||||
|
border: var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 400;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 1em;
|
||||||
|
width: 500px;
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.small-button {
|
||||||
|
font-size: 0.75em;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-track-group {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-track-group > * {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
margin-right: 1ch !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pre-join-controls {
|
||||||
|
width: 60%;
|
||||||
|
margin: 0 auto;
|
||||||
|
margin-top: 4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#join-session-form {
|
||||||
|
margin-bottom: 4em;
|
||||||
|
}
|
|
@ -0,0 +1,69 @@
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::atomic::{AtomicUsize, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
use futures::{SinkExt, StreamExt, TryFutureExt};
|
||||||
|
use tokio::sync::{
|
||||||
|
mpsc::{self, UnboundedSender},
|
||||||
|
RwLock,
|
||||||
|
};
|
||||||
|
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
use warp::ws::{Message, WebSocket};
|
||||||
|
|
||||||
|
static CONNECTED_VIEWERS: Lazy<RwLock<HashMap<usize, ConnectedViewer>>> =
|
||||||
|
Lazy::new(|| RwLock::new(HashMap::new()));
|
||||||
|
static NEXT_VIEWER_ID: AtomicUsize = AtomicUsize::new(1);
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "op", content = "data")]
|
||||||
|
pub enum WatchEvent {
|
||||||
|
SetPlaying { playing: bool, time: u64 },
|
||||||
|
SetTime(u64),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ConnectedViewer {
|
||||||
|
pub session_uuid: Uuid,
|
||||||
|
pub tx: UnboundedSender<WatchEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ws_subscribe(session_uuid: Uuid, ws: WebSocket) {
|
||||||
|
let viewer_id = NEXT_VIEWER_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let (mut viewer_ws_tx, mut viewer_ws_rx) = ws.split();
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel::<WatchEvent>();
|
||||||
|
let mut rx = UnboundedReceiverStream::new(rx);
|
||||||
|
|
||||||
|
tokio::task::spawn(async move {
|
||||||
|
while let Some(event) = rx.next().await {
|
||||||
|
viewer_ws_tx
|
||||||
|
.send(Message::text(
|
||||||
|
serde_json::to_string(&event).expect("couldn't convert WatchEvent into JSON"),
|
||||||
|
))
|
||||||
|
.unwrap_or_else(|e| eprintln!("ws send error: {}", e))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
CONNECTED_VIEWERS
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(viewer_id, ConnectedViewer { session_uuid, tx });
|
||||||
|
while let Some(Ok(_)) = viewer_ws_rx.next().await {}
|
||||||
|
CONNECTED_VIEWERS.write().await.remove(&viewer_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ws_publish(session_uuid: Uuid, event: WatchEvent) {
|
||||||
|
for viewer in CONNECTED_VIEWERS.read().await.values() {
|
||||||
|
if viewer.session_uuid != session_uuid {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = viewer.tx.send(event.clone());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,140 @@
|
||||||
|
use std::{collections::HashMap, sync::Mutex};
|
||||||
|
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use warb::{hyper::StatusCode, Filter, Reply};
|
||||||
|
use warp as warb; // i think it's funny
|
||||||
|
|
||||||
|
mod events;
|
||||||
|
mod watch_session;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
events::{ws_publish, ws_subscribe, WatchEvent},
|
||||||
|
watch_session::{SubtitleTrack, WatchSession},
|
||||||
|
};
|
||||||
|
|
||||||
|
static SESSIONS: Lazy<Mutex<HashMap<Uuid, WatchSession>>> =
|
||||||
|
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct StartSessionBody {
|
||||||
|
pub video_url: String,
|
||||||
|
#[serde(default = "Vec::new")]
|
||||||
|
pub subtitle_tracks: Vec<SubtitleTrack>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let start_session_route = warb::path!("start_session")
|
||||||
|
.and(warb::path::end())
|
||||||
|
.and(warb::post())
|
||||||
|
.and(warb::body::json())
|
||||||
|
.map(|body: StartSessionBody| {
|
||||||
|
let mut sessions = SESSIONS.lock().unwrap();
|
||||||
|
let session_uuid = Uuid::new_v4();
|
||||||
|
let session = WatchSession::new(body.video_url, body.subtitle_tracks);
|
||||||
|
let session_view = session.view();
|
||||||
|
sessions.insert(session_uuid, session);
|
||||||
|
|
||||||
|
warb::reply::json(&json!({ "id": session_uuid.to_string(), "session": session_view }))
|
||||||
|
});
|
||||||
|
|
||||||
|
enum RequestedSession {
|
||||||
|
Session(Uuid, WatchSession),
|
||||||
|
Error(warb::reply::WithStatus<warb::reply::Json>),
|
||||||
|
}
|
||||||
|
|
||||||
|
let get_running_session = warb::path::path("sess")
|
||||||
|
.and(warb::path::param::<String>())
|
||||||
|
.map(|session_id: String| {
|
||||||
|
if let Ok(uuid) = Uuid::parse_str(&session_id) {
|
||||||
|
if let Some(session) = SESSIONS.lock().unwrap().get(&uuid) {
|
||||||
|
RequestedSession::Session(uuid, session.clone())
|
||||||
|
} else {
|
||||||
|
RequestedSession::Error(warb::reply::with_status(
|
||||||
|
warb::reply::json(&json!({ "error": "session does not exist" })),
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
RequestedSession::Error(warb::reply::with_status(
|
||||||
|
warb::reply::json(&json!({ "error": "invalid session UUID" })),
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let get_status_route = get_running_session
|
||||||
|
.and(warb::path::end())
|
||||||
|
.map(|requested_session| match requested_session {
|
||||||
|
RequestedSession::Session(_, sess) => {
|
||||||
|
warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK)
|
||||||
|
}
|
||||||
|
RequestedSession::Error(e) => e,
|
||||||
|
});
|
||||||
|
|
||||||
|
let set_playing_route = get_running_session
|
||||||
|
.and(warb::path!("playing"))
|
||||||
|
.and(warb::put())
|
||||||
|
.and(warb::body::json())
|
||||||
|
.map(|requested_session, playing: bool| match requested_session {
|
||||||
|
RequestedSession::Session(uuid, mut sess) => {
|
||||||
|
sess.set_playing(playing);
|
||||||
|
let time = sess.get_time_ms();
|
||||||
|
SESSIONS.lock().unwrap().insert(uuid, sess.clone());
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
ws_publish(uuid, WatchEvent::SetPlaying { playing, time }).await
|
||||||
|
});
|
||||||
|
|
||||||
|
warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK)
|
||||||
|
}
|
||||||
|
RequestedSession::Error(e) => e,
|
||||||
|
});
|
||||||
|
|
||||||
|
let set_timestamp_route = get_running_session
|
||||||
|
.and(warb::path!("current_time"))
|
||||||
|
.and(warb::put())
|
||||||
|
.and(warb::body::json())
|
||||||
|
.map(
|
||||||
|
|requested_session, current_time_ms: u64| match requested_session {
|
||||||
|
RequestedSession::Session(uuid, mut sess) => {
|
||||||
|
sess.set_time_ms(current_time_ms);
|
||||||
|
SESSIONS.lock().unwrap().insert(uuid, sess.clone());
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
ws_publish(uuid, WatchEvent::SetTime(current_time_ms)).await
|
||||||
|
});
|
||||||
|
|
||||||
|
warb::reply::with_status(warb::reply::json(&sess.view()), StatusCode::OK)
|
||||||
|
}
|
||||||
|
RequestedSession::Error(e) => e,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let ws_subscribe_route = get_running_session
|
||||||
|
.and(warp::path!("subscribe"))
|
||||||
|
.and(warp::ws())
|
||||||
|
.map(
|
||||||
|
|requested_session, ws: warb::ws::Ws| match requested_session {
|
||||||
|
RequestedSession::Session(uuid, _) => ws
|
||||||
|
.on_upgrade(move |ws| ws_subscribe(uuid, ws))
|
||||||
|
.into_response(),
|
||||||
|
RequestedSession::Error(error_response) => error_response.into_response(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let routes = start_session_route
|
||||||
|
.or(get_status_route)
|
||||||
|
.or(set_playing_route)
|
||||||
|
.or(set_timestamp_route)
|
||||||
|
.or(ws_subscribe_route)
|
||||||
|
.or(warb::path::end().and(warb::fs::file("frontend/index.html")))
|
||||||
|
.or(warb::fs::dir("frontend"));
|
||||||
|
|
||||||
|
warb::serve(routes).run(([127, 0, 0, 1], 3000)).await;
|
||||||
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub struct SubtitleTrack {
|
||||||
|
pub url: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WatchSession {
|
||||||
|
pub video_url: String,
|
||||||
|
pub subtitle_tracks: Vec<SubtitleTrack>,
|
||||||
|
|
||||||
|
is_playing: bool,
|
||||||
|
playing_from_timestamp: u64,
|
||||||
|
playing_from_instant: Instant,
|
||||||
|
// TODO: How do we keep track of the current playing time ?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct WatchSessionView {
|
||||||
|
pub video_url: String,
|
||||||
|
pub subtitle_tracks: Vec<SubtitleTrack>,
|
||||||
|
pub current_time_ms: u64,
|
||||||
|
pub is_playing: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WatchSession {
|
||||||
|
pub fn new(video_url: String, subtitle_tracks: Vec<SubtitleTrack>) -> Self {
|
||||||
|
WatchSession {
|
||||||
|
video_url,
|
||||||
|
subtitle_tracks,
|
||||||
|
is_playing: false,
|
||||||
|
playing_from_timestamp: 0,
|
||||||
|
playing_from_instant: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view(&self) -> WatchSessionView {
|
||||||
|
WatchSessionView {
|
||||||
|
video_url: self.video_url.clone(),
|
||||||
|
subtitle_tracks: self.subtitle_tracks.clone(),
|
||||||
|
current_time_ms: self.get_time_ms() as u64,
|
||||||
|
is_playing: self.is_playing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_time_ms(&self) -> u64 {
|
||||||
|
if !self.is_playing {
|
||||||
|
return self.playing_from_timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.playing_from_timestamp + self.playing_from_instant.elapsed().as_millis() as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_time_ms(&mut self, time_ms: u64) {
|
||||||
|
self.playing_from_timestamp = time_ms;
|
||||||
|
self.playing_from_instant = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_playing(&mut self, playing: bool) {
|
||||||
|
self.set_time_ms(self.get_time_ms());
|
||||||
|
self.is_playing = playing;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue