video playback and timeline playhead
This commit is contained in:
27
AZKiServer/Controllers/MediaController.cs
Normal file
27
AZKiServer/Controllers/MediaController.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using AZKiServer.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
using MongoDB.Bson;
|
||||
|
||||
namespace AZKiServer.Controllers;
|
||||
|
||||
[Route("/m/")]
|
||||
public class MediaController(MediaService mediaService, IConfiguration configuration) : Controller
|
||||
{
|
||||
private readonly string _basePath = configuration["SCAN_LOCATION"] ?? throw new NullReferenceException("SCAN_LOCATION is not set");
|
||||
|
||||
[HttpGet("v/{id}")]
|
||||
[ResponseCache(Duration = int.MaxValue)]
|
||||
public async Task<IActionResult> VideoAsync(ObjectId id)
|
||||
{
|
||||
var media = await mediaService.GetEntryAsync(id);
|
||||
if (media == null)
|
||||
return NotFound();
|
||||
if (media.Type != Models.MediaType.Video)
|
||||
return BadRequest();
|
||||
var filePath = Path.Combine(_basePath, media.Filepath);
|
||||
//var fs = new FileStream(, FileMode.Open);
|
||||
return PhysicalFile(filePath, "video/mp4", true);
|
||||
}
|
||||
}
|
||||
@@ -53,10 +53,10 @@ builder.Services.AddCors(o =>
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseRouting();
|
||||
app.UseGrpcWeb(new GrpcWebOptions { DefaultEnabled = true });
|
||||
app.UseCors();
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
app.UseCors();
|
||||
|
||||
|
||||
//app.UseAuthentication();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using AZKiServer.Models;
|
||||
|
||||
using MaybeError;
|
||||
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using MongoDB.Driver;
|
||||
@@ -39,6 +41,11 @@ public class MediaService(IMongoDatabase db)
|
||||
return await _entries.Find(filter).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MediaEntry?> GetEntryAsync(ObjectId id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _entries.Find(e => e.Id == id).FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAllEntriesAsync(IEnumerable<ObjectId> ids, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _entries.DeleteManyAsync(e => ids.Contains(e.Id), cancellationToken);
|
||||
|
||||
@@ -7,18 +7,30 @@
|
||||
background-color: $darkBackground;
|
||||
display: grid;
|
||||
grid-template-areas: "Head ControlPanel" "ViewPort ControlPanel" "Timeline Timeline";
|
||||
grid-template-rows: auto 1fr 500px;
|
||||
grid-template-rows: 50px calc(100dvh - 350px - 20px) 300px;
|
||||
grid-template-columns: 1fr auto;
|
||||
max-height: calc(100dvh - 20px);
|
||||
}
|
||||
|
||||
#viewport {
|
||||
grid-area: ViewPort;
|
||||
border: 1px solid $lightBackground;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
overflow: hidden;
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
image-rendering: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
#timeline {
|
||||
grid-area: Timeline;
|
||||
border: 1px solid $lightBackground;
|
||||
$labelWidth: 100px;
|
||||
|
||||
#tracklist {
|
||||
display: flex;
|
||||
@@ -27,14 +39,34 @@
|
||||
width: 100%;
|
||||
gap: 1px;
|
||||
}
|
||||
.track {
|
||||
display: block;
|
||||
|
||||
#playhead {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding: 2px 0;
|
||||
height: 30px;
|
||||
grid-template-columns: $labelWidth 1fr;
|
||||
grid-template-areas: "Label Input";
|
||||
input {
|
||||
grid-area: Input;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
width: 1px;
|
||||
background-color: $secondaryAccentLight;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.track {
|
||||
display: grid;
|
||||
min-height: 30px;
|
||||
background-color: $lightBackground;
|
||||
grid-template-columns: $labelWidth 1fr;
|
||||
/* overflow: hidden; */
|
||||
margin-left: 100px;
|
||||
/* margin-left: 100px; */
|
||||
|
||||
.label {
|
||||
width: 100px;
|
||||
@@ -42,11 +74,15 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
left: -100px;
|
||||
left: 0;
|
||||
top: 0px;
|
||||
background-color: $featureBackround;
|
||||
}
|
||||
.clips {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: 0px 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.clip {
|
||||
|
||||
@@ -15,13 +15,14 @@ use crate::{
|
||||
const PLAYER_CSS: Asset = asset!("/assets/styling/player.scss");
|
||||
|
||||
#[component]
|
||||
pub fn Player() -> Element {
|
||||
pub fn Player() -> Element
|
||||
{
|
||||
let mut selected_date = use_signal(|| {
|
||||
let now = Local::now();
|
||||
Some(Date::from_ordinal_date(now.year(), now.ordinal() as u16).unwrap())
|
||||
});
|
||||
let mut zoom = use_signal(|| 1.0 as f32);
|
||||
let mut time = use_signal(|| 0 as i64);
|
||||
let time = use_signal(|| 0 as i64);
|
||||
let playbackResult = use_resource(use_reactive!(|(selected_date)| async move {
|
||||
let mut client = get_rpc_client();
|
||||
info!("Load data");
|
||||
@@ -30,17 +31,22 @@ pub fn Player() -> Element {
|
||||
let result = client
|
||||
.get_media_playback(MediaPlaybackRequest { date: Some(from) })
|
||||
.await;
|
||||
if let Ok(entries) = result {
|
||||
if let Ok(entries) = result
|
||||
{
|
||||
let res = entries.into_inner();
|
||||
return Ok(res);
|
||||
} else {
|
||||
}
|
||||
else
|
||||
{
|
||||
let err = result.err().unwrap();
|
||||
let msg = err.message();
|
||||
return Err(format!("Failed to load results: {msg}"));
|
||||
}
|
||||
}));
|
||||
let info = match playbackResult.cloned() {
|
||||
Some(value) => match value {
|
||||
let info = match playbackResult.cloned()
|
||||
{
|
||||
Some(value) => match value
|
||||
{
|
||||
Ok(result) => Some(result),
|
||||
Err(_) => None,
|
||||
},
|
||||
@@ -73,10 +79,10 @@ pub fn Player() -> Element {
|
||||
selected_date.set(v);
|
||||
},
|
||||
DatePickerInput{}
|
||||
},
|
||||
}
|
||||
}
|
||||
Viewport { }
|
||||
Timeline { playbackInfo: info, zoom, time }
|
||||
Viewport { playback_info: info.clone(), time }
|
||||
Timeline { playback_info: info, zoom, time }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,67 @@
|
||||
use chrono::DateTime;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::rpc::azki::{MediaChannel, MediaEntry, PlaybackInfo};
|
||||
|
||||
#[component]
|
||||
pub fn Timeline(playbackInfo: Option<PlaybackInfo>, zoom: Signal<f32>, time: Signal<i64>) -> Element {
|
||||
return match playbackInfo {
|
||||
pub fn Timeline(playback_info: Option<PlaybackInfo>, zoom: Signal<f32>, time: Signal<i64>) -> Element
|
||||
{
|
||||
return match playback_info
|
||||
{
|
||||
Some(info) => rsx! {
|
||||
div{
|
||||
id: "timeline",
|
||||
PlayHead { time, zoom }
|
||||
TrackList { channels: info.channels, start: info.date.unwrap().seconds, zoom }
|
||||
TrackList { channels: info.channels, start: info.date.unwrap().seconds, zoom, time }
|
||||
}
|
||||
},
|
||||
None => rsx! {
|
||||
div{
|
||||
id: "timeline",
|
||||
PlayHead { time, zoom }
|
||||
TrackList { channels: Vec::new(), start: 0, zoom }
|
||||
TrackList { channels: Vec::new(), start: 0, zoom, time }
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn PlayHead(time: Signal<i64>, zoom: Signal<f32>) -> Element {
|
||||
rsx! {}
|
||||
fn PlayHead(start: i64, time: Signal<i64>, zoom: Signal<f32>) -> Element
|
||||
{
|
||||
let time_string = get_time_string(start, time());
|
||||
|
||||
let p = (time() as f32 / SEC_PER_DAY) * 100.0;
|
||||
rsx! {
|
||||
div {
|
||||
id: "playhead",
|
||||
input {
|
||||
style: "width: calc({zoom() * 100.0}%);",
|
||||
type: "range",
|
||||
value: "{time()}",
|
||||
max: "{SEC_PER_DAY}",
|
||||
oninput: move |e|{
|
||||
if let Ok(t) = e.value().parse() {
|
||||
time.set(t);
|
||||
}
|
||||
}
|
||||
},
|
||||
div{
|
||||
class: "time",
|
||||
"{time_string}"
|
||||
}
|
||||
div{
|
||||
class: "indicator",
|
||||
style: "left: {p}%"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TrackList(channels: Vec<MediaChannel>, start: i64, zoom: Signal<f32>) -> Element {
|
||||
fn TrackList(channels: Vec<MediaChannel>, start: i64, zoom: Signal<f32>, time: Signal<i64>) -> Element
|
||||
{
|
||||
rsx! {
|
||||
div {
|
||||
id: "tracklist",
|
||||
PlayHead { start, time, zoom }
|
||||
{channels.iter().map(|c|rsx!{
|
||||
TimelineTrack { channel: c.clone(), start, zoom }
|
||||
})}
|
||||
@@ -40,19 +70,24 @@ fn TrackList(channels: Vec<MediaChannel>, start: i64, zoom: Signal<f32>) -> Elem
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TimelineTrack(channel: MediaChannel, start: i64, zoom: Signal<f32>) -> Element {
|
||||
fn TimelineTrack(channel: MediaChannel, start: i64, zoom: Signal<f32>) -> Element
|
||||
{
|
||||
rsx! {
|
||||
div{
|
||||
class: "track",
|
||||
style: "width: calc({zoom() * 100.0}% - 100px);",
|
||||
style: "width: calc({zoom() * 100.0}%);",
|
||||
TrackLabel { channel: channel.clone() },
|
||||
div{
|
||||
class: "clips",
|
||||
{channel.videos.iter().map(|m|rsx!{Clip{media: m.clone(), start}})}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TrackLabel(channel: MediaChannel) -> Element {
|
||||
fn TrackLabel(channel: MediaChannel) -> Element
|
||||
{
|
||||
rsx! {
|
||||
div{
|
||||
class: "label",
|
||||
@@ -63,7 +98,8 @@ fn TrackLabel(channel: MediaChannel) -> Element {
|
||||
|
||||
const SEC_PER_DAY: f32 = 86400.0;
|
||||
#[component]
|
||||
fn Clip(media: MediaEntry, start: i64) -> Element {
|
||||
fn Clip(media: MediaEntry, start: i64) -> Element
|
||||
{
|
||||
let meta = media.metadata.unwrap();
|
||||
let date = media.date.unwrap();
|
||||
let timestamp = date.seconds - start;
|
||||
@@ -73,7 +109,13 @@ fn Clip(media: MediaEntry, start: i64) -> Element {
|
||||
div{
|
||||
class: "clip",
|
||||
style: "width: {duration}%; left: {offset}%",
|
||||
"{timestamp / 60 / 60}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_time_string(start: i64, time: i64) -> String
|
||||
{
|
||||
let date_time = DateTime::from_timestamp_secs(start + time).expect("Failed to convert time");
|
||||
let local = date_time.naive_local();
|
||||
return local.to_string();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,76 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::{
|
||||
rpc::azki::{MediaChannel, MediaEntry, PlaybackInfo},
|
||||
MEDIA_HOST,
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn Viewport() -> Element {
|
||||
pub fn Viewport(playback_info: Option<PlaybackInfo>, time: Signal<i64>) -> Element
|
||||
{
|
||||
match playback_info
|
||||
{
|
||||
Some(info) => rsx! { ViewportFull{playback_info: info, time} },
|
||||
None => rsx! { EmptyViewport{} },
|
||||
}
|
||||
}
|
||||
#[component]
|
||||
fn ViewportFull(playback_info: PlaybackInfo, time: Signal<i64>) -> Element
|
||||
{
|
||||
let start = playback_info.date.expect("Failed to get date of playback").seconds;
|
||||
let cur_time = time();
|
||||
|
||||
rsx! {
|
||||
div{
|
||||
id: "viewport"
|
||||
id: "viewport",
|
||||
{playback_info.channels.iter().map(|c| {
|
||||
let entry = get_entry(c, start, cur_time);
|
||||
rsx!{
|
||||
Video{ entry }
|
||||
}
|
||||
})}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn EmptyViewport() -> Element
|
||||
{
|
||||
rsx! {
|
||||
div{
|
||||
id: "viewport",
|
||||
"Empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_entry(channel: &MediaChannel, start: i64, time: i64) -> Option<MediaEntry>
|
||||
{
|
||||
return channel
|
||||
.videos
|
||||
.iter()
|
||||
.find(|e| {
|
||||
let meta = e.metadata.unwrap();
|
||||
let date = e.date.unwrap();
|
||||
let timestamp = date.seconds - start;
|
||||
|
||||
return timestamp <= time && timestamp + meta.duration as i64 >= time;
|
||||
})
|
||||
.map(|m| m.clone());
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Video(entry: Option<MediaEntry>) -> Element
|
||||
{
|
||||
match entry
|
||||
{
|
||||
Some(entry) => rsx! {
|
||||
video{
|
||||
autoplay: "true",
|
||||
muted: "true",
|
||||
src: "{MEDIA_HOST}/m/v/{entry.id}"
|
||||
}
|
||||
},
|
||||
None => rsx! { div{ "No Video" }},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,17 @@ mod route;
|
||||
mod rpc;
|
||||
mod views;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub const MEDIA_HOST: &'static str = "http://localhost:5177";
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub const MEDIA_HOST: &'static str = "";
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub const RPC_HOST: &'static str = "http://localhost:5177";
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub const RPC_HOST: &'static str = "https://grpc.aoba.app:8443";
|
||||
|
||||
fn main() {
|
||||
fn main()
|
||||
{
|
||||
dioxus::launch(App);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user