timeline rendering
This commit is contained in:
@@ -33,9 +33,20 @@ public static class ConversionExtensions
|
|||||||
Version = entry.Version,
|
Version = entry.Version,
|
||||||
CameraId = entry.CameraId,
|
CameraId = entry.CameraId,
|
||||||
Date = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(entry.Date),
|
Date = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(entry.Date),
|
||||||
|
Metadata = entry.Metadata.ToRpc(),
|
||||||
FilePath = entry.Filepath,
|
FilePath = entry.Filepath,
|
||||||
Id = entry.Id.ToString(),
|
Id = entry.Id.ToString(),
|
||||||
Type = entry.Type.ToRpc()
|
Type = entry.Type.ToRpc()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static MediaMetadata ToRpc(this Models.MediaMetadata metadata)
|
||||||
|
{
|
||||||
|
return new MediaMetadata
|
||||||
|
{
|
||||||
|
Duration = metadata.Duration,
|
||||||
|
Height = metadata.Height,
|
||||||
|
Width = metadata.Width
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ public class FileScannerService(MediaService mediaService, IConfiguration config
|
|||||||
{
|
{
|
||||||
if (_isRunning)
|
if (_isRunning)
|
||||||
return;
|
return;
|
||||||
|
logger.LogInformation("Starting file scan");
|
||||||
_isRunning = true;
|
_isRunning = true;
|
||||||
ScanFilesAsync(path).Wait();
|
ScanFilesAsync(path).Wait();
|
||||||
|
logger.LogInformation("File scan complete");
|
||||||
_isRunning = false;
|
_isRunning = false;
|
||||||
}, null, TimeSpan.FromMinutes(0), TimeSpan.FromHours(1));
|
}, null, TimeSpan.FromMinutes(0), TimeSpan.FromHours(1));
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@@ -57,7 +59,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config
|
|||||||
foreach (var chunk in files.Chunk(50))
|
foreach (var chunk in files.Chunk(50))
|
||||||
{
|
{
|
||||||
total += await ScanFileChunkAsync(path, chunk, existingFiles, cancellationToken);
|
total += await ScanFileChunkAsync(path, chunk, existingFiles, cancellationToken);
|
||||||
logger.LogInformation("Added {updated} of {count}", total, existingFiles.Count);
|
logger.LogInformation("Added {updated} of {count}", total, files.Length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -80,7 +82,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config
|
|||||||
var isUpgrade = false;
|
var isUpgrade = false;
|
||||||
if (existingFiles.TryGetValue(relativePath, out var version))
|
if (existingFiles.TryGetValue(relativePath, out var version))
|
||||||
{
|
{
|
||||||
if (version < MediaEntry.CUR_VERSION)
|
if (version <= MediaEntry.CUR_VERSION)
|
||||||
continue;
|
continue;
|
||||||
isUpgrade = true;
|
isUpgrade = true;
|
||||||
}
|
}
|
||||||
@@ -111,7 +113,7 @@ public class FileScannerService(MediaService mediaService, IConfiguration config
|
|||||||
{
|
{
|
||||||
await mediaService.DeleteAllEntriesAsync(upgradeEntries.Select(e => e.Filepath), cancellationToken);
|
await mediaService.DeleteAllEntriesAsync(upgradeEntries.Select(e => e.Filepath), cancellationToken);
|
||||||
await mediaService.AddMediaBulkAsync(upgradeEntries, cancellationToken);
|
await mediaService.AddMediaBulkAsync(upgradeEntries, cancellationToken);
|
||||||
logger.LogInformation("Upgraded {count} file entries", entries.Count);
|
logger.LogInformation("Upgraded {count} file entries", upgradeEntries.Count);
|
||||||
}
|
}
|
||||||
return entries.Count + upgradeEntries.Count;
|
return entries.Count + upgradeEntries.Count;
|
||||||
}
|
}
|
||||||
@@ -142,10 +144,17 @@ public class FileScannerService(MediaService mediaService, IConfiguration config
|
|||||||
|
|
||||||
|
|
||||||
private static Maybe<MediaMetadata> ReadVideoMetadata(string filePath)
|
private static Maybe<MediaMetadata> ReadVideoMetadata(string filePath)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var info = FFProbe.Analyse(filePath);
|
var info = FFProbe.Analyse(filePath);
|
||||||
if (info.PrimaryVideoStream == null)
|
if (info.PrimaryVideoStream == null)
|
||||||
return new Error($"Could not find a primirary video stream in file.");
|
return new Error($"Could not find a primirary video stream in file.");
|
||||||
return new MediaMetadata((int)info.Duration.TotalSeconds, info.PrimaryVideoStream.Height, info.PrimaryVideoStream.Width);
|
return new MediaMetadata((int)info.Duration.TotalSeconds, info.PrimaryVideoStream.Height, info.PrimaryVideoStream.Width);
|
||||||
}
|
}
|
||||||
|
catch(Exception ex)
|
||||||
|
{
|
||||||
|
return ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ public class MediaService(IMongoDatabase db)
|
|||||||
public readonly IMongoCollection<MediaEntry> _entries = db.GetCollection<MediaEntry>("media");
|
public readonly IMongoCollection<MediaEntry> _entries = db.GetCollection<MediaEntry>("media");
|
||||||
|
|
||||||
[BsonIgnoreExtraElements]
|
[BsonIgnoreExtraElements]
|
||||||
private record MediaInfo(string FilePath, int Version);
|
private record class MediaInfo(string Filepath, int Version);
|
||||||
public async Task<FrozenDictionary<string, int>> GetExistingFilePathsAsync(CancellationToken cancellationToken = default)
|
public async Task<FrozenDictionary<string, int>> GetExistingFilePathsAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var files = await _entries.Find("{}").As<MediaInfo>().ToListAsync(cancellationToken);
|
var files = await _entries.Find("{}").As<MediaInfo>().ToListAsync(cancellationToken);
|
||||||
if (files.Count == 0)
|
if (files.Count == 0)
|
||||||
return FrozenDictionary<string, int>.Empty;
|
return FrozenDictionary<string, int>.Empty;
|
||||||
return files.ToFrozenDictionary(m => m.FilePath, m => m.Version);
|
return files.ToFrozenDictionary(m => m.Filepath, m => m.Version);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AddMediaBulkAsync(List<MediaEntry> entries, CancellationToken cancellationToken = default)
|
public async Task AddMediaBulkAsync(List<MediaEntry> entries, CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -19,4 +19,44 @@
|
|||||||
#timeline {
|
#timeline {
|
||||||
grid-area: Timeline;
|
grid-area: Timeline;
|
||||||
border: 1px solid $lightBackground;
|
border: 1px solid $lightBackground;
|
||||||
|
|
||||||
|
#tracklist {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: auto;
|
||||||
|
width: 100%;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
.track {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
padding: 2px 0;
|
||||||
|
height: 30px;
|
||||||
|
background-color: $lightBackground;
|
||||||
|
/* overflow: hidden; */
|
||||||
|
margin-left: 100px;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
width: 100px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: absolute;
|
||||||
|
left: -100px;
|
||||||
|
top: 0px;
|
||||||
|
background-color: $featureBackround;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
height: 30px;
|
||||||
|
top: auto;
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: $mainAccentDark;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use chrono::{Datelike, Days, Local};
|
use chrono::{Datelike, Local};
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use prost_types::Timestamp;
|
use prost_types::Timestamp;
|
||||||
|
|
||||||
@@ -10,10 +10,10 @@ const PLAYER_CSS: Asset = asset!("/assets/styling/player.scss");
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Player() -> Element {
|
pub fn Player() -> Element {
|
||||||
let entries = use_resource(|| async move {
|
let playbackResult = use_resource(|| async move {
|
||||||
let mut client = get_rpc_client();
|
let mut client = get_rpc_client();
|
||||||
let now = Local::now();
|
let now = Local::now();
|
||||||
let from = Timestamp::date(now.year() as i64, now.month() as u8, now.day() as u8).unwrap();
|
let from = Timestamp::date(now.year() as i64, now.month() as u8, now.day() as u8 - 4).unwrap();
|
||||||
let result = client
|
let result = client
|
||||||
.get_media_playback(MediaPlaybackRequest { date: Some(from) })
|
.get_media_playback(MediaPlaybackRequest { date: Some(from) })
|
||||||
.await;
|
.await;
|
||||||
@@ -26,12 +26,12 @@ pub fn Player() -> Element {
|
|||||||
return Err(format!("Failed to load results: {msg}"));
|
return Err(format!("Failed to load results: {msg}"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let len = match entries.cloned() {
|
let info = match playbackResult.cloned() {
|
||||||
Some(value) => match value {
|
Some(value) => match value {
|
||||||
Ok(result) => result.channels.len().to_string(),
|
Ok(result) => Some(result),
|
||||||
Err(err) => err,
|
Err(_) => None,
|
||||||
},
|
},
|
||||||
_ => "Not Loaded".to_string(),
|
_ => None,
|
||||||
};
|
};
|
||||||
rsx! {
|
rsx! {
|
||||||
document::Link { rel: "stylesheet", href: PLAYER_CSS }
|
document::Link { rel: "stylesheet", href: PLAYER_CSS }
|
||||||
@@ -39,10 +39,9 @@ pub fn Player() -> Element {
|
|||||||
id: "player",
|
id: "player",
|
||||||
div {
|
div {
|
||||||
id: "head",
|
id: "head",
|
||||||
"r {len}"
|
|
||||||
}
|
}
|
||||||
Viewport { }
|
Viewport { }
|
||||||
Timeline { }
|
Timeline { playbackInfo: info }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,73 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::rpc::azki::{MediaChannel, MediaEntry, PlaybackInfo};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Timeline() -> Element {
|
pub fn Timeline(playbackInfo: Option<PlaybackInfo>) -> Element {
|
||||||
rsx! {
|
return match playbackInfo {
|
||||||
|
Some(info) => rsx! {
|
||||||
div{
|
div{
|
||||||
id: "timeline"
|
id: "timeline",
|
||||||
|
TrackList { channels: info.channels, start: info.date.unwrap().seconds, zoom: 2.0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => rsx! {
|
||||||
|
div{
|
||||||
|
id: "timeline",
|
||||||
|
TrackList { channels: Vec::new(), start: 0, zoom: 1.0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn TrackList(channels: Vec<MediaChannel>, start: i64, zoom: f32) -> Element {
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
id: "tracklist",
|
||||||
|
{channels.iter().map(|c|rsx!{
|
||||||
|
TimelineTrack { channel: c.clone(), start, zoom }
|
||||||
|
})}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn TimelineTrack(channel: MediaChannel, start: i64, zoom: f32) -> Element {
|
||||||
|
rsx! {
|
||||||
|
div{
|
||||||
|
class: "track",
|
||||||
|
style: "width: calc({zoom * 100.0}% - 100px);",
|
||||||
|
TrackLabel { channel: channel.clone() },
|
||||||
|
{channel.videos.iter().map(|m|rsx!{Clip{media: m.clone(), start}})}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn TrackLabel(channel: MediaChannel) -> Element {
|
||||||
|
rsx! {
|
||||||
|
div{
|
||||||
|
class: "label",
|
||||||
|
"Camera {channel.camera_id}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEC_PER_DAY: f32 = 86400.0;
|
||||||
|
#[component]
|
||||||
|
fn Clip(media: MediaEntry, start: i64) -> Element {
|
||||||
|
let meta = media.metadata.unwrap();
|
||||||
|
let date = media.date.unwrap();
|
||||||
|
let timestamp = date.seconds - start;
|
||||||
|
let duration = (meta.duration as f32 / SEC_PER_DAY) * 100.0;
|
||||||
|
let offset = (timestamp as f32 / SEC_PER_DAY) * 100.0;
|
||||||
|
let time = date.to_string();
|
||||||
|
rsx! {
|
||||||
|
div{
|
||||||
|
class: "clip",
|
||||||
|
style: "width: {duration}%; left: {offset}%",
|
||||||
|
"{timestamp / 60 / 60}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user