Initial commit: Beacon digital signage application
This commit is contained in:
commit
5c5aa88507
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
6043
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
40
Cargo.toml
Normal file
40
Cargo.toml
Normal 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
53
README.md
Normal 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
11
beacon.desktop
Normal 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
9
digital-sign.desktop
Normal 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
BIN
icons/icon_128.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
BIN
icons/icon_256.png
Normal file
BIN
icons/icon_256.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 89 KiB |
BIN
icons/icon_32.png
Normal file
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
BIN
icons/icon_64.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.4 KiB |
30
install.sh
Executable file
30
install.sh
Executable 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
50
src/config.rs
Normal 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
663
src/main.rs
Normal 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) = ¤t_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
92
src/pocketbase.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue