Initial commit: Beacon digital signage application

This commit is contained in:
RTSDA 2025-02-06 00:12:04 -05:00
commit 5c5aa88507
14 changed files with 7016 additions and 0 deletions

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# Generated by Cargo
/target/
**/*.rs.bk
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
# Cargo.lock
# IDE specific files
.idea/
.vscode/
*.swp
*.swo
# Config files that might contain sensitive information
config.toml
config.json
# macOS specific
.DS_Store
# Backup files
*.bak
*.backup
*~

6043
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

40
Cargo.toml Normal file
View file

@ -0,0 +1,40 @@
[package]
name = "beacon"
version = "0.1.0"
edition = "2021"
description = "A modern digital signage application for displaying church events"
authors = ["Benjamin Slingo"]
[package.metadata.bundle]
name = "Beacon"
identifier = "church.rockvilletollandsda.beacon"
icon = ["icons/icon_256.png", "icons/icon_128.png", "icons/icon_64.png", "icons/icon_32.png"]
version = "1.0.0"
resources = ["icons/*"]
copyright = "© 2024 Rockville Tolland SDA Church"
category = "Office"
short_description = "Digital signage for church events"
long_description = """
A digital signage application that displays upcoming church events,
announcements, and information in a beautiful and engaging way.
"""
[dependencies]
tokio = { version = "1.36", features = ["full"] }
iced = { git = "https://github.com/iced-rs/iced.git", features = ["image", "tokio", "advanced", "debug", "system"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1"
tracing-subscriber = "0.3"
config = "0.14"
once_cell = "1.19"
html2text = "0.12"
toml = "0.8"
dirs = "5.0"
ril = { version = "0.10", features = ["all"] }
infer = "0.15"
[package.metadata.iced.assets]
icon = "icons/appicon.png"

53
README.md Normal file
View file

@ -0,0 +1,53 @@
# Beacon
A modern digital signage application for displaying church events, built with Rust and Iced.
## Features
- Real-time event display with automatic updates
- Smooth image loading and transitions
- Modern, clean interface design
- Automatic event filtering based on date/time
- Support for high-resolution displays
- Efficient memory management for images
## Requirements
- Rust 1.70 or higher
- A running Pocketbase instance with events collection
## Configuration
Create a `config.toml` file in the application directory with the following settings:
```toml
pocketbase_url = "http://your-pocketbase-url"
window_width = 1920
window_height = 1080
slide_interval_secs = 10
refresh_interval_mins = 5
```
## Building
```bash
cargo build --release
```
## Running
```bash
./target/release/beacon
```
## Development
The application is built using:
- Iced for the UI framework
- Tokio for async runtime
- Reqwest for HTTP requests
- Chrono for date/time handling
## License
MIT License

11
beacon.desktop Normal file
View file

@ -0,0 +1,11 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Beacon
Comment=Digital signage application for church events
Exec=/usr/local/bin/beacon
Icon=beacon
Terminal=false
Categories=Office;Graphics;
Keywords=church;events;signage;display
StartupNotify=true

9
digital-sign.desktop Normal file
View file

@ -0,0 +1,9 @@
[Desktop Entry]
Name= Beacon Digital Signage
Comment=Digital signage for church events
Exec=/usr/local/bin/beacon
Icon=appicon.png
Terminal=false
Type=Application
Categories=Office;Graphics;
StartupNotify=true

BIN
icons/icon_128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
icons/icon_256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
icons/icon_32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
icons/icon_64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

30
install.sh Executable file
View file

@ -0,0 +1,30 @@
#!/bin/bash
# Build the application
cargo build --release
# Create necessary directories
sudo mkdir -p /usr/local/bin
sudo mkdir -p /usr/share/applications
sudo mkdir -p /usr/share/icons/hicolor/{32x32,64x64,128x128,256x256}/apps
# Copy the binary
sudo cp target/release/beacon /usr/local/bin/
# Copy the desktop file
sudo cp beacon.desktop /usr/share/applications/
# Copy icons to the appropriate directories
sudo cp icons/icon_32.png /usr/share/icons/hicolor/32x32/apps/beacon.png
sudo cp icons/icon_64.png /usr/share/icons/hicolor/64x64/apps/beacon.png
sudo cp icons/icon_128.png /usr/share/icons/hicolor/128x128/apps/beacon.png
sudo cp icons/icon_256.png /usr/share/icons/hicolor/256x256/apps/beacon.png
# Update icon cache
sudo gtk-update-icon-cache -f /usr/share/icons/hicolor
# Set permissions
sudo chmod +x /usr/local/bin/beacon
sudo chmod 644 /usr/share/applications/beacon.desktop
echo "Installation complete. You can now launch Beacon from your application menu."

50
src/config.rs Normal file
View file

@ -0,0 +1,50 @@
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
#[derive(Debug, Deserialize)]
pub struct Settings {
pub pocketbase_url: String,
pub window_width: i32,
pub window_height: i32,
pub slide_interval_seconds: u64,
pub refresh_interval_minutes: u64,
}
impl Settings {
pub fn new() -> anyhow::Result<Self> {
let config_path = Self::config_path()?;
let contents = fs::read_to_string(config_path)?;
let settings: Settings = toml::from_str(&contents)?;
Ok(settings)
}
pub fn slide_interval(&self) -> Duration {
Duration::from_secs(self.slide_interval_seconds)
}
pub fn refresh_interval(&self) -> Duration {
Duration::from_secs(self.refresh_interval_minutes * 60)
}
fn config_path() -> anyhow::Result<PathBuf> {
let mut path = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
path.push("digital-sign");
path.push("config.toml");
Ok(path)
}
}
impl Default for Settings {
fn default() -> Self {
Self {
pocketbase_url: String::from("https://pocketbase.rockvilletollandsda.church"),
window_width: 1920,
window_height: 1080,
slide_interval_seconds: 10,
refresh_interval_minutes: 5,
}
}
}

663
src/main.rs Normal file
View file

@ -0,0 +1,663 @@
mod config;
mod pocketbase;
use crate::pocketbase::PocketbaseEvent;
use iced::widget::{column, row, image, container, text};
use iced::{
window, Element,
Length, Settings, Subscription, Theme, Task,
};
use iced::executor;
pub use iced::Program as IcedProgram;
use iced::Color;
use once_cell::sync::Lazy;
use std::time::Instant;
use iced::window::settings::PlatformSpecific;
static SETTINGS: Lazy<config::Settings> = Lazy::new(|| {
config::Settings::new().unwrap_or_else(|e| {
eprintln!("Failed to load config, using defaults: {}", e);
config::Settings::default()
})
});
static POCKETBASE_CLIENT: Lazy<pocketbase::PocketbaseClient> = Lazy::new(|| {
pocketbase::PocketbaseClient::new(SETTINGS.pocketbase_url.clone())
});
// Define some constants for styling
const BACKGROUND_COLOR: Color = Color::from_rgb(0.05, 0.05, 0.08); // Slightly blue-tinted dark background
const ACCENT_COLOR: Color = Color::from_rgb(0.45, 0.27, 0.85); // Vibrant purple
const TEXT_COLOR: Color = Color::from_rgb(0.98, 0.98, 1.0);
const SECONDARY_TEXT_COLOR: Color = Color::from_rgb(0.85, 0.85, 0.95);
const CATEGORY_COLOR: Color = Color::from_rgb(0.45, 0.27, 0.85); // Match accent color
const DESCRIPTION_BG_COLOR: Color = Color::from_rgb(0.1, 0.1, 0.15); // Slightly blue-tinted
const TITLE_COLOR: Color = Color::from_rgb(1.0, 1.0, 0.95); // Warm white
const DATE_COLOR: Color = Color::from_rgb(0.95, 0.85, 1.0); // Light purple tint
const TIME_COLOR: Color = Color::from_rgb(0.8, 0.8, 0.95); // Soft purple-grey
const LOCATION_ICON_COLOR: Color = Color::from_rgb(0.6, 0.4, 0.9); // Brighter purple
const IMAGE_BG_COLOR: Color = Color::from_rgb(0.08, 0.08, 0.12); // Slightly lighter than background
const LOADING_FRAMES: [&str; 4] = ["", "", "", ""];
const MAX_IMAGE_SIZE: u64 = 500 * 1024; // 500KB limit
#[derive(Debug)]
struct DigitalSign {
events: Vec<Event>,
current_event_index: usize,
last_update: Instant,
last_refresh: Instant,
loaded_images: std::collections::HashMap<String, image::Handle>,
loading_frame: usize,
is_fetching: bool,
}
#[derive(Debug, Clone)]
struct Event {
title: String,
description: String,
start_time: String,
end_time: String,
date: String,
location: String,
//location_url: Option<String>,
image_url: Option<String>,
category: String,
//is_featured: bool,
timestamp: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
enum Message {
Tick,
EventsLoaded(Vec<Event>),
Error(String),
ImageLoaded(String, image::Handle),
}
impl IcedProgram for DigitalSign {
type Message = Message;
type Theme = Theme;
type Executor = executor::Default;
type State = Self;
type Renderer = iced::Renderer;
fn title(&self, _state: &Self::State, _window_id: window::Id) -> String {
String::from("Beacon")
}
fn update(&self, state: &mut Self::State, message: Message) -> Task<Message> {
match message {
Message::Tick => {
let mut tasks = vec![];
state.loading_frame = (state.loading_frame + 1) % LOADING_FRAMES.len();
if state.should_refresh() && !state.is_fetching {
tracing::info!("Refresh needed, starting event fetch");
state.is_fetching = true;
tasks.push(Task::perform(fetch_events(), Message::handle_result));
}
if !state.events.is_empty()
&& Instant::now().duration_since(state.last_update) >= SETTINGS.slide_interval()
{
let next_index = (state.current_event_index + 1) % state.events.len();
tracing::info!("Updating current event index from {} to {}",
state.current_event_index,
next_index
);
// Clear all images that aren't needed anymore
let mut urls_to_remove = Vec::new();
for url in state.loaded_images.keys() {
let is_needed = state.events.iter().any(|e| {
e.image_url.as_ref().map_or(false, |event_url| event_url == url)
});
if !is_needed {
urls_to_remove.push(url.clone());
}
}
for url in urls_to_remove {
tracing::info!("Removing unused image: {}", url);
state.loaded_images.remove(&url);
}
// Update current index and load new image if needed
state.current_event_index = next_index;
state.last_update = Instant::now();
if let Some(current_event) = state.events.get(state.current_event_index) {
if let Some(url) = &current_event.image_url {
let url_clone = url.clone();
if !state.loaded_images.contains_key(&url_clone) {
tracing::info!("Starting image load for new current event: {}", url_clone);
let url_for_closure = url_clone.clone();
tasks.push(Task::perform(
load_image(url_clone),
move |handle| Message::ImageLoaded(url_for_closure.clone(), handle)
));
} else {
tracing::info!("Image already loaded for current event: {}", url_clone);
}
}
}
}
if tasks.is_empty() {
Task::none()
} else {
tracing::info!("Dispatching {} tasks", tasks.len());
Task::batch(tasks)
}
}
Message::EventsLoaded(events) => {
tracing::info!("Events loaded: {} events", events.len());
// Clear all existing images as we have a new set of events
state.loaded_images.clear();
tracing::info!("Cleared all existing images");
state.events = events;
state.last_refresh = Instant::now();
state.is_fetching = false;
// Load all images in parallel
let mut image_tasks = Vec::new();
// First, add the current event's image if it exists
if let Some(event) = state.events.get(state.current_event_index) {
if let Some(url) = &event.image_url {
tracing::info!("Starting immediate load for current image: {}", url);
let url_clone = url.clone();
image_tasks.push(Task::perform(
load_image(url_clone.clone()),
move |handle| Message::ImageLoaded(url_clone.clone(), handle)
));
}
}
// Then queue the rest of the images
for (index, event) in state.events.iter().enumerate() {
if index != state.current_event_index {
if let Some(url) = &event.image_url {
tracing::info!("Queueing image preload for: {}", url);
let url_clone = url.clone();
image_tasks.push(Task::perform(
load_image(url_clone.clone()),
move |handle| Message::ImageLoaded(url_clone.clone(), handle)
));
}
}
}
if !image_tasks.is_empty() {
tracing::info!("Starting load of {} images", image_tasks.len());
Task::batch(image_tasks)
} else {
Task::none()
}
}
Message::ImageLoaded(url, handle) => {
tracing::info!("Image loaded: {}", url);
state.loaded_images.insert(url, handle);
Task::none()
}
Message::Error(error) => {
tracing::error!("Error: {}", error);
state.is_fetching = false;
Task::none()
}
}
}
fn view<'a>(
&self,
state: &'a Self::State,
_window_id: window::Id,
) -> Element<'a, Message, Theme, Self::Renderer> {
let content: Element<'a, Message, Theme, Self::Renderer> = if let Some(event) = state.events.get(state.current_event_index) {
let mut main_column = column![].spacing(40).padding(60).width(Length::Fill);
// Left column with title and image
let left_column = column![
// Title with dynamic size and enhanced color
container(
text(&event.title)
.size(if event.title.len() > 50 { 72 } else { 88 })
.style(|_: &Theme| text::Style { color: Some(TITLE_COLOR), ..Default::default() })
)
.width(Length::Fill)
.padding(20),
// Image container with enhanced styling
container(
if let Some(ref image_url) = event.image_url {
if let Some(handle) = state.loaded_images.get(image_url) {
container(
image::Image::new(handle.clone())
.width(Length::Fixed(900.0))
.height(Length::Fixed(600.0))
)
.style(|_: &Theme| container::Style {
background: Some(IMAGE_BG_COLOR.into()),
..Default::default()
})
} else {
container(
column![
text(LOADING_FRAMES[state.loading_frame])
.size(80)
.style(|_: &Theme| text::Style { color: Some(ACCENT_COLOR), ..Default::default() }),
text("Loading image...")
.size(40)
.style(|_: &Theme| text::Style { color: Some(SECONDARY_TEXT_COLOR), ..Default::default() })
]
.spacing(20)
.align_x(iced::alignment::Horizontal::Center)
)
}
} else {
container(
text("No image available")
.size(32)
.style(|_: &Theme| text::Style { color: Some(SECONDARY_TEXT_COLOR), ..Default::default() })
)
}
)
.width(Length::Fixed(900.0))
.height(Length::Fixed(600.0))
.style(|_: &Theme| container::Style {
background: Some(IMAGE_BG_COLOR.into()),
..Default::default()
})
]
.spacing(20);
// Right column with category, date/time, location, and description
let right_column = column![
// Category badge with gradient-like effect
container(
text(event.category.to_uppercase())
.size(36)
.style(|_: &Theme| text::Style { color: Some(TEXT_COLOR), ..Default::default() })
)
.padding(12)
.style(|_: &Theme| container::Style {
background: Some(CATEGORY_COLOR.into()),
..Default::default()
}),
// Date and time with enhanced colors
container(
column![
text(&event.date)
.size(64)
.style(|_: &Theme| text::Style { color: Some(DATE_COLOR), ..Default::default() }),
text(format!("{} - {}", event.start_time, event.end_time))
.size(56)
.style(|_: &Theme| text::Style { color: Some(TIME_COLOR), ..Default::default() })
]
.spacing(15)
)
.padding(20),
// Location with colored icon
if !event.location.is_empty() {
container(
row![
text("") // Using a more compatible location/target symbol
.size(48)
.style(|_: &Theme| text::Style { color: Some(LOCATION_ICON_COLOR), ..Default::default() }),
text(&event.location)
.size(48)
.style(|_: &Theme| text::Style { color: Some(SECONDARY_TEXT_COLOR), ..Default::default() })
]
.spacing(15)
.align_y(iced::Alignment::Center)
)
.padding(20)
} else {
container(text(""))
},
// Description with styled background
container(
text(&event.description)
.size(44)
.style(|_: &Theme| text::Style { color: Some(TEXT_COLOR), ..Default::default() })
)
.width(Length::Fill)
.height(Length::Fill)
.padding(25)
.style(|_: &Theme| container::Style {
background: Some(DESCRIPTION_BG_COLOR.into()),
..Default::default()
})
]
.spacing(30)
.width(Length::Fill)
.height(Length::Fill);
// Main content row
let content_row = row![
left_column,
right_column
]
.spacing(60)
.height(Length::Fill);
main_column = main_column.push(content_row);
container(main_column)
.width(Length::Fill)
.height(Length::Fill)
.center_y(Length::Fill)
.into()
} else {
container(
text("Loading events...")
.size(64)
.style(|_: &Theme| text::Style { color: Some(ACCENT_COLOR), ..Default::default() })
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
};
container(content)
.width(Length::Fill)
.height(Length::Fill)
.style(|_: &Theme| container::Style {
background: Some(BACKGROUND_COLOR.into()),
..Default::default()
})
.into()
}
fn subscription(&self, _state: &Self::State) -> Subscription<Message> {
iced::time::every(std::time::Duration::from_millis(100))
.map(|_| Message::Tick)
}
fn theme(&self, _state: &Self::State, _window_id: window::Id) -> Theme {
Theme::Dark
}
}
impl Message {
fn handle_result(result: Result<Vec<Event>, anyhow::Error>) -> Self {
match result {
Ok(events) => Message::EventsLoaded(events),
Err(e) => Message::Error(e.to_string()),
}
}
}
async fn fetch_events() -> Result<Vec<Event>, anyhow::Error> {
tracing::info!("Starting to fetch events from Pocketbase");
let pb_events = match POCKETBASE_CLIENT.fetch_events().await {
Ok(events) => {
tracing::info!("Successfully fetched {} events from Pocketbase", events.len());
events
},
Err(e) => {
tracing::error!("Failed to fetch events from Pocketbase: {}", e);
return Err(e);
}
};
// Use a 12-hour window for filtering
let now = chrono::Utc::now() - chrono::Duration::hours(12);
let mut events: Vec<Event> = pb_events
.into_iter()
.filter(|event| {
let is_current = event.end_time > now;
if !is_current {
tracing::info!(
"Filtering out event '{}' with end time {} (now is {})",
event.title,
event.end_time,
now
);
}
is_current
})
.map(Event::from)
.collect();
if events.is_empty() {
tracing::warn!("No current or future events found");
} else {
tracing::info!(
"Found {} events, from {} to {}",
events.len(),
events.first().map(|e| e.date.as_str()).unwrap_or("unknown"),
events.last().map(|e| e.date.as_str()).unwrap_or("unknown")
);
}
events.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
tracing::info!("Processed and sorted {} current/future events", events.len());
Ok(events)
}
async fn load_image(url: String) -> image::Handle {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.expect("Failed to create HTTP client");
// First check the content length
let head_resp = match client.head(&url).send().await {
Ok(resp) => resp,
Err(e) => {
tracing::error!("Failed to fetch image head {}: {}", url, e);
return image::Handle::from_bytes(vec![]);
}
};
if let Some(content_length) = head_resp.content_length() {
tracing::info!("Image size for {}: {} KB", url, content_length / 1024);
if content_length > MAX_IMAGE_SIZE {
tracing::warn!("Image too large ({}KB), skipping download", content_length / 1024);
return image::Handle::from_bytes(vec![]);
}
}
let response = match client.get(&url).send().await {
Ok(resp) => resp,
Err(e) => {
tracing::error!("Failed to fetch image {}: {}", url, e);
return image::Handle::from_bytes(vec![]);
}
};
match response.bytes().await {
Ok(bytes) => {
if bytes.len() as u64 > MAX_IMAGE_SIZE {
tracing::warn!("Image too large after download ({}KB), skipping", bytes.len() / 1024);
return image::Handle::from_bytes(vec![]);
}
tracing::info!("Successfully downloaded image {} with {} bytes", url, bytes.len());
image::Handle::from_bytes(bytes.to_vec())
}
Err(e) => {
tracing::error!("Failed to get image bytes for {}: {}", url, e);
image::Handle::from_bytes(vec![])
}
}
}
impl From<PocketbaseEvent> for Event {
fn from(event: PocketbaseEvent) -> Self {
let clean_description = html2text::from_read(event.description.as_bytes(), 80)
.replace('\n', " ")
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ");
let date = event.start_time.format("%A, %B %d, %Y").to_string();
let start_time = event.start_time.format("%I:%M %p").to_string().trim_start_matches('0').to_string();
let end_time = event.end_time.format("%I:%M %p").to_string().trim_start_matches('0').to_string();
let image_url = event.image.map(|img| {
let url = format!(
"{}/api/files/events/{}/{}",
SETTINGS.pocketbase_url,
event.id,
img
);
tracing::info!("Constructed image URL: {}", url);
url
});
Self {
title: event.title,
description: clean_description,
start_time,
end_time,
date,
location: event.location,
//location_url: event.location_url,
image_url,
category: event.category,
//is_featured: event.is_featured,
timestamp: event.start_time,
}
}
}
fn main() -> iced::Result {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
tracing::info!("Starting Beacon Digital Signage");
tracing::info!("Pocketbase URL: {}", SETTINGS.pocketbase_url);
// Load the icon file
let icon_data = {
// Try local development path first
let local_path = "icons/appicon.png";
// Try system-wide installation path
let system_paths = [
"/usr/share/icons/hicolor/256x256/apps/beacon.png",
"/usr/local/share/icons/hicolor/256x256/apps/beacon.png",
];
let mut icon_bytes = None;
// Try local path first
if let Ok(bytes) = std::fs::read(local_path) {
tracing::info!("Found icon in local path: {}", local_path);
icon_bytes = Some(bytes);
} else {
// Try system paths
for path in system_paths.iter() {
if let Ok(bytes) = std::fs::read(path) {
tracing::info!("Found icon in system path: {}", path);
icon_bytes = Some(bytes);
break;
}
}
}
// Create icon from bytes if we found any
if let Some(bytes) = icon_bytes {
match window::icon::from_file_data(&bytes, None) {
Ok(icon) => {
tracing::info!("Successfully created icon from data");
Some(icon)
}
Err(e) => {
tracing::error!("Failed to create icon from data: {}", e);
None
}
}
} else {
tracing::error!("Could not find icon file in any location");
None
}
};
let window_settings = window::Settings {
size: iced::Size::new(SETTINGS.window_width as f32, SETTINGS.window_height as f32),
position: window::Position::Centered,
fullscreen: true,
resizable: false,
decorations: false,
icon: icon_data,
#[cfg(target_os = "macos")]
platform_specific: PlatformSpecific {
title_hidden: true,
titlebar_transparent: true,
fullsize_content_view: true,
},
#[cfg(not(target_os = "macos"))]
platform_specific: PlatformSpecific::default(),
..Default::default()
};
let settings = Settings {
// window: window_settings,
//flags: (),
fonts: vec![],
default_font: iced::Font::default(),
antialiasing: true,
..Default::default()
};
// Create the initial state and start loading events
let mut app = DigitalSign::default();
app.is_fetching = true;
DigitalSign::run_with(
app,
settings,
Some(window_settings),
|| {
let mut state = DigitalSign::default();
state.is_fetching = true;
(
state,
Task::perform(
fetch_events(),
Message::handle_result
)
)
}
)
}
impl DigitalSign {
fn should_refresh(&self) -> bool {
let elapsed = self.last_refresh.elapsed();
let interval = SETTINGS.refresh_interval();
let should_refresh = elapsed >= interval;
tracing::info!(
"Checking refresh: elapsed={:?}, interval={:?}, should_refresh={}",
elapsed,
interval,
should_refresh
);
should_refresh
}
}
impl Default for DigitalSign {
fn default() -> Self {
Self {
events: vec![],
current_event_index: 0,
last_update: Instant::now(),
last_refresh: Instant::now(),
loaded_images: std::collections::HashMap::new(),
loading_frame: 0,
is_fetching: false,
}
}
}

92
src/pocketbase.rs Normal file
View file

@ -0,0 +1,92 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::time::Duration;
const POCKETBASE_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Serialize, Deserialize)]
pub struct PocketbaseEvent {
pub id: String,
pub title: String,
pub description: String,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub location: String,
pub location_url: Option<String>,
pub image: Option<String>,
pub thumbnail: Option<String>,
pub category: String,
pub is_featured: bool,
pub reoccuring: String,
pub created: DateTime<Utc>,
pub updated: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct PocketbaseClient {
client: reqwest::Client,
base_url: String,
}
impl PocketbaseClient {
pub fn new(base_url: String) -> Self {
let client = reqwest::Client::builder()
.timeout(POCKETBASE_TIMEOUT)
.build()
.expect("Failed to create HTTP client");
Self { client, base_url }
}
pub async fn fetch_events(&self) -> Result<Vec<PocketbaseEvent>> {
// Subtract 12 hours from now to include upcoming events
let now = (chrono::Utc::now() - chrono::Duration::hours(12)).to_rfc3339();
let url = format!(
"{}/api/collections/events/records?filter=(end_time>='{}')",
self.base_url,
now
);
tracing::info!("Fetching events from URL: {}", url);
let response = match self.client.get(&url)
.header("Cache-Control", "max-age=60") // Cache for 60 seconds
.send()
.await
{
Ok(resp) => {
tracing::info!("Got response with status: {}", resp.status());
resp
},
Err(e) => {
tracing::error!("HTTP request failed: {}", e);
return Err(e.into());
}
};
let response = match response.error_for_status() {
Ok(resp) => resp,
Err(e) => {
tracing::error!("HTTP error status: {}", e);
return Err(e.into());
}
};
#[derive(Deserialize)]
struct Response {
items: Vec<PocketbaseEvent>,
}
match response.json().await {
Ok(data) => {
let Response { items } = data;
tracing::info!("Successfully parsed {} events from response", items.len());
Ok(items)
},
Err(e) => {
tracing::error!("Failed to parse JSON response: {}", e);
Err(e.into())
}
}
}
}