From 78651ca58d825b1022b9d0396a1ff6e41894cf17 Mon Sep 17 00:00:00 2001 From: Amatsugu Date: Thu, 5 Mar 2026 19:18:45 -0500 Subject: [PATCH] video playback and timeline playhead --- AZKiServer/Controllers/MediaController.cs | 27 ++++++++ AZKiServer/Program.cs | 4 +- AZKiServer/Services/MediaService.cs | 7 +++ client/assets/styling/player.scss | 52 +++++++++++++--- client/src/components/playback/player.rs | 24 +++++--- client/src/components/playback/timeline.rs | 72 +++++++++++++++++----- client/src/components/playback/viewport.rs | 70 ++++++++++++++++++++- client/src/main.rs | 8 ++- 8 files changed, 227 insertions(+), 37 deletions(-) create mode 100644 AZKiServer/Controllers/MediaController.cs diff --git a/AZKiServer/Controllers/MediaController.cs b/AZKiServer/Controllers/MediaController.cs new file mode 100644 index 0000000..4dca4fb --- /dev/null +++ b/AZKiServer/Controllers/MediaController.cs @@ -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 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); + } +} diff --git a/AZKiServer/Program.cs b/AZKiServer/Program.cs index 2dae7fa..1f8a305 100644 --- a/AZKiServer/Program.cs +++ b/AZKiServer/Program.cs @@ -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(); diff --git a/AZKiServer/Services/MediaService.cs b/AZKiServer/Services/MediaService.cs index d4ff07f..87221ff 100644 --- a/AZKiServer/Services/MediaService.cs +++ b/AZKiServer/Services/MediaService.cs @@ -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 GetEntryAsync(ObjectId id, CancellationToken cancellationToken = default) + { + return await _entries.Find(e => e.Id == id).FirstOrDefaultAsync(cancellationToken); + } + public async Task DeleteAllEntriesAsync(IEnumerable ids, CancellationToken cancellationToken = default) { await _entries.DeleteManyAsync(e => ids.Contains(e.Id), cancellationToken); diff --git a/client/assets/styling/player.scss b/client/assets/styling/player.scss index 3ae5cec..bd854b5 100644 --- a/client/assets/styling/player.scss +++ b/client/assets/styling/player.scss @@ -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 { diff --git a/client/src/components/playback/player.rs b/client/src/components/playback/player.rs index bb9902a..95aa869 100644 --- a/client/src/components/playback/player.rs +++ b/client/src/components/playback/player.rs @@ -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 } } } } diff --git a/client/src/components/playback/timeline.rs b/client/src/components/playback/timeline.rs index e6b23af..a61a16d 100644 --- a/client/src/components/playback/timeline.rs +++ b/client/src/components/playback/timeline.rs @@ -1,37 +1,67 @@ +use chrono::DateTime; use dioxus::prelude::*; use crate::rpc::azki::{MediaChannel, MediaEntry, PlaybackInfo}; #[component] -pub fn Timeline(playbackInfo: Option, zoom: Signal, time: Signal) -> Element { - return match playbackInfo { +pub fn Timeline(playback_info: Option, zoom: Signal, time: Signal) -> 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, zoom: Signal) -> Element { - rsx! {} +fn PlayHead(start: i64, time: Signal, zoom: Signal) -> 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, start: i64, zoom: Signal) -> Element { +fn TrackList(channels: Vec, start: i64, zoom: Signal, time: Signal) -> 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, start: i64, zoom: Signal) -> Elem } #[component] -fn TimelineTrack(channel: MediaChannel, start: i64, zoom: Signal) -> Element { +fn TimelineTrack(channel: MediaChannel, start: i64, zoom: Signal) -> Element +{ rsx! { div{ class: "track", - style: "width: calc({zoom() * 100.0}% - 100px);", + style: "width: calc({zoom() * 100.0}%);", TrackLabel { channel: channel.clone() }, - {channel.videos.iter().map(|m|rsx!{Clip{media: m.clone(), start}})} + 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(); +} diff --git a/client/src/components/playback/viewport.rs b/client/src/components/playback/viewport.rs index e7f889e..dc34ad4 100644 --- a/client/src/components/playback/viewport.rs +++ b/client/src/components/playback/viewport.rs @@ -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, time: Signal) -> Element +{ + match playback_info + { + Some(info) => rsx! { ViewportFull{playback_info: info, time} }, + None => rsx! { EmptyViewport{} }, + } +} +#[component] +fn ViewportFull(playback_info: PlaybackInfo, time: Signal) -> 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 +{ + 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) -> Element +{ + match entry + { + Some(entry) => rsx! { + video{ + autoplay: "true", + muted: "true", + src: "{MEDIA_HOST}/m/v/{entry.id}" + } + }, + None => rsx! { div{ "No Video" }}, + } +} diff --git a/client/src/main.rs b/client/src/main.rs index 357cb75..80a00f1 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -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); }