start at 3am
This commit is contained in:
@@ -53,33 +53,60 @@ pub fn get_image_base64(path: String) -> Result<String, String> {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_timeline(date: String, base_dir: String) -> Vec<serde_json::Value> {
|
pub fn get_timeline(date: String, base_dir: String) -> Vec<serde_json::Value> {
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
let dir_path = PathBuf::from(base_dir).join(date);
|
let base_path = PathBuf::from(base_dir);
|
||||||
|
|
||||||
if !dir_path.exists() || !dir_path.is_dir() {
|
// Parse current date
|
||||||
return results;
|
let current_date = match chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d") {
|
||||||
}
|
Ok(d) => d,
|
||||||
|
Err(_) => return results,
|
||||||
|
};
|
||||||
|
let next_date = current_date + chrono::Duration::days(1);
|
||||||
|
let next_date_str = next_date.format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
if let Ok(entries) = fs::read_dir(dir_path) {
|
// Helper to process a directory with a time filter
|
||||||
let mut paths: Vec<_> = entries
|
let mut process_dir = |dir_date: &str, is_next_day: bool| {
|
||||||
.filter_map(|e| e.ok())
|
let dir_path = base_path.join(dir_date);
|
||||||
.map(|e| e.path())
|
if let Ok(entries) = fs::read_dir(dir_path) {
|
||||||
.filter(|p| p.is_file() && p.extension().and_then(|s| s.to_str()) == Some("jpg"))
|
for entry in entries.flatten() {
|
||||||
.collect();
|
let path = entry.path();
|
||||||
|
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("jpg") {
|
||||||
paths.sort();
|
if let Some(file_name) = path.file_stem().and_then(|s| s.to_str()) {
|
||||||
|
// file_name format: "14-30-00"
|
||||||
for path in paths {
|
let parts: Vec<&str> = file_name.split('-').collect();
|
||||||
if let Some(file_name) = path.file_stem().and_then(|s| s.to_str()) {
|
if parts.len() >= 1 {
|
||||||
// file_name format: "14-30-00" or "14-30-00_0"
|
if let Ok(hour) = parts[0].parse::<u32>() {
|
||||||
let time_str = file_name.replace("-", ":").replace("_", " ");
|
// Logic: If current day, must be >= 3. If next day, must be < 3.
|
||||||
results.push(serde_json::json!({
|
let keep = if !is_next_day { hour >= 3 } else { hour < 3 };
|
||||||
"time": time_str,
|
if keep {
|
||||||
"path": path.to_string_lossy().to_string()
|
let time_str = file_name.replace("-", ":").replace("_", " ");
|
||||||
}));
|
results.push(serde_json::json!({
|
||||||
|
"time": time_str,
|
||||||
|
"path": path.to_string_lossy().to_string(),
|
||||||
|
"isNextDay": is_next_day
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
process_dir(&date, false);
|
||||||
|
process_dir(&next_date_str, true);
|
||||||
|
|
||||||
|
// Sort results by isNextDay (false first) then by time
|
||||||
|
results.sort_by(|a, b| {
|
||||||
|
let a_next = a["isNextDay"].as_bool().unwrap_or(false);
|
||||||
|
let b_next = b["isNextDay"].as_bool().unwrap_or(false);
|
||||||
|
if a_next != b_next {
|
||||||
|
a_next.cmp(&b_next)
|
||||||
|
} else {
|
||||||
|
a["time"].as_str().unwrap_or("").cmp(b["time"].as_str().unwrap_or(""))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
results
|
results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
67
src/App.vue
67
src/App.vue
@@ -10,11 +10,13 @@ import { FolderOpen, Settings, Play, Pause, Maximize2, X, RefreshCw } from "luci
|
|||||||
interface TimelineItem {
|
interface TimelineItem {
|
||||||
time: string; // "HH:mm:ss"
|
time: string; // "HH:mm:ss"
|
||||||
path: string;
|
path: string;
|
||||||
|
isNextDay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PIXELS_PER_MINUTE = 1.5; // Controls the vertical stretch of the timeline
|
const PIXELS_PER_MINUTE = 1.5; // Controls the vertical stretch of the timeline
|
||||||
const TOTAL_MINUTES = 24 * 60;
|
const TOTAL_MINUTES = 24 * 60;
|
||||||
const RULER_HEIGHT = TOTAL_MINUTES * PIXELS_PER_MINUTE;
|
const RULER_HEIGHT = TOTAL_MINUTES * PIXELS_PER_MINUTE;
|
||||||
|
const TIME_OFFSET_MINUTES = 3 * 60; // 03:00 logical day start
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
const isSetupComplete = ref(false);
|
const isSetupComplete = ref(false);
|
||||||
@@ -23,7 +25,7 @@ const isPaused = ref(false);
|
|||||||
const currentDate = ref(new Date().toISOString().split("T")[0]);
|
const currentDate = ref(new Date().toISOString().split("T")[0]);
|
||||||
const timelineImages = ref<TimelineItem[]>([]);
|
const timelineImages = ref<TimelineItem[]>([]);
|
||||||
const selectedImage = ref<TimelineItem | null>(null);
|
const selectedImage = ref<TimelineItem | null>(null);
|
||||||
const lockedImage = ref<TimelineItem | null>(null); // State for the clicked/locked point
|
const lockedImage = ref<TimelineItem | null>(null);
|
||||||
const previewSrc = ref("");
|
const previewSrc = ref("");
|
||||||
const isFullscreen = ref(false);
|
const isFullscreen = ref(false);
|
||||||
const isSettingsOpen = ref(false);
|
const isSettingsOpen = ref(false);
|
||||||
@@ -47,10 +49,7 @@ onMounted(async () => {
|
|||||||
mergeScreens.value = (await store.get("mergeScreens")) as boolean || false;
|
mergeScreens.value = (await store.get("mergeScreens")) as boolean || false;
|
||||||
retainDays.value = (await store.get("retainDays")) as number || 30;
|
retainDays.value = (await store.get("retainDays")) as number || 30;
|
||||||
captureInterval.value = (await store.get("captureInterval")) as number || 30;
|
captureInterval.value = (await store.get("captureInterval")) as number || 30;
|
||||||
|
|
||||||
// Sync initial interval to Rust
|
|
||||||
await invoke("update_interval", { seconds: captureInterval.value });
|
await invoke("update_interval", { seconds: captureInterval.value });
|
||||||
|
|
||||||
isPaused.value = await invoke("get_pause_state");
|
isPaused.value = await invoke("get_pause_state");
|
||||||
await loadTimeline();
|
await loadTimeline();
|
||||||
}
|
}
|
||||||
@@ -59,7 +58,6 @@ onMounted(async () => {
|
|||||||
isPaused.value = event.payload;
|
isPaused.value = event.payload;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for refresh triggers if any
|
|
||||||
captureUnlisten = await listen("refresh-timeline", () => {
|
captureUnlisten = await listen("refresh-timeline", () => {
|
||||||
loadTimeline();
|
loadTimeline();
|
||||||
});
|
});
|
||||||
@@ -85,8 +83,6 @@ const updateSettings = async () => {
|
|||||||
await store.set("retainDays", retainDays.value);
|
await store.set("retainDays", retainDays.value);
|
||||||
await store.set("captureInterval", captureInterval.value);
|
await store.set("captureInterval", captureInterval.value);
|
||||||
await store.save();
|
await store.save();
|
||||||
|
|
||||||
// Sync interval to Rust
|
|
||||||
await invoke("update_interval", { seconds: captureInterval.value });
|
await invoke("update_interval", { seconds: captureInterval.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,16 +98,13 @@ const loadTimeline = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Internal function to update preview with caching/optimization
|
|
||||||
const updatePreview = async (img: TimelineItem | null) => {
|
const updatePreview = async (img: TimelineItem | null) => {
|
||||||
if (!img) {
|
if (!img) {
|
||||||
selectedImage.value = null;
|
selectedImage.value = null;
|
||||||
previewSrc.value = "";
|
previewSrc.value = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedImage.value?.path === img.path && previewSrc.value) return;
|
if (selectedImage.value?.path === img.path && previewSrc.value) return;
|
||||||
|
|
||||||
selectedImage.value = img;
|
selectedImage.value = img;
|
||||||
try {
|
try {
|
||||||
const base64 = await invoke("get_image_base64", { path: img.path });
|
const base64 = await invoke("get_image_base64", { path: img.path });
|
||||||
@@ -121,23 +114,30 @@ const updatePreview = async (img: TimelineItem | null) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Timeline Helper Functions ---
|
// --- Helper Functions for Logical Day ---
|
||||||
const timeToMinutes = (timeStr: string) => {
|
|
||||||
|
// Converts absolute HH:mm to minutes relative to 03:00 start
|
||||||
|
const timeToLogicalMinutes = (timeStr: string, isNextDay = false) => {
|
||||||
const [h, m] = timeStr.split(":").map(Number);
|
const [h, m] = timeStr.split(":").map(Number);
|
||||||
return h * 60 + m;
|
let total = h * 60 + m;
|
||||||
|
if (isNextDay) total += 24 * 60;
|
||||||
|
return total - TIME_OFFSET_MINUTES;
|
||||||
};
|
};
|
||||||
|
|
||||||
const minutesToTime = (totalMinutes: number) => {
|
// Converts logical minutes (0 to 1439) back to HH:mm
|
||||||
const h = Math.floor(totalMinutes / 60);
|
const logicalMinutesToTime = (logicalMinutes: number) => {
|
||||||
const m = Math.floor(totalMinutes % 60);
|
let total = logicalMinutes + TIME_OFFSET_MINUTES;
|
||||||
|
total = total % (24 * 60);
|
||||||
|
const h = Math.floor(total / 60);
|
||||||
|
const m = Math.floor(total % 60);
|
||||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const findClosestImage = (minutes: number) => {
|
const findClosestImage = (logicalMin: number) => {
|
||||||
if (timelineImages.value.length === 0) return null;
|
if (timelineImages.value.length === 0) return null;
|
||||||
return timelineImages.value.reduce((prev, curr) => {
|
return timelineImages.value.reduce((prev, curr) => {
|
||||||
const prevDiff = Math.abs(timeToMinutes(prev.time) - minutes);
|
const prevDiff = Math.abs(timeToLogicalMinutes(prev.time, prev.isNextDay) - logicalMin);
|
||||||
const currDiff = Math.abs(timeToMinutes(curr.time) - minutes);
|
const currDiff = Math.abs(timeToLogicalMinutes(curr.time, curr.isNextDay) - logicalMin);
|
||||||
return currDiff < prevDiff ? curr : prev;
|
return currDiff < prevDiff ? curr : prev;
|
||||||
}, timelineImages.value[0]);
|
}, timelineImages.value[0]);
|
||||||
};
|
};
|
||||||
@@ -146,32 +146,27 @@ const handleTimelineMouseMove = (e: MouseEvent) => {
|
|||||||
if (!timelineRef.value) return;
|
if (!timelineRef.value) return;
|
||||||
const rect = timelineRef.value.getBoundingClientRect();
|
const rect = timelineRef.value.getBoundingClientRect();
|
||||||
const y = e.clientY - rect.top + timelineRef.value.scrollTop;
|
const y = e.clientY - rect.top + timelineRef.value.scrollTop;
|
||||||
const minutes = Math.floor(y / PIXELS_PER_MINUTE);
|
const logicalMin = Math.floor(y / PIXELS_PER_MINUTE);
|
||||||
|
|
||||||
if (minutes >= 0 && minutes < TOTAL_MINUTES) {
|
if (logicalMin >= 0 && logicalMin < TOTAL_MINUTES) {
|
||||||
hoveredTime.value = minutesToTime(minutes);
|
hoveredTime.value = logicalMinutesToTime(logicalMin);
|
||||||
const closest = findClosestImage(minutes);
|
const closest = findClosestImage(logicalMin);
|
||||||
if (closest) {
|
if (closest) updatePreview(closest);
|
||||||
updatePreview(closest);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimelineMouseLeave = () => {
|
const handleTimelineMouseLeave = () => {
|
||||||
hoveredTime.value = null;
|
hoveredTime.value = null;
|
||||||
// Revert to locked image if exists, otherwise keep current
|
if (lockedImage.value) updatePreview(lockedImage.value);
|
||||||
if (lockedImage.value) {
|
|
||||||
updatePreview(lockedImage.value);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimelineClick = (e: MouseEvent) => {
|
const handleTimelineClick = (e: MouseEvent) => {
|
||||||
if (!timelineRef.value) return;
|
if (!timelineRef.value) return;
|
||||||
const rect = timelineRef.value.getBoundingClientRect();
|
const rect = timelineRef.value.getBoundingClientRect();
|
||||||
const y = e.clientY - rect.top + timelineRef.value.scrollTop;
|
const y = e.clientY - rect.top + timelineRef.value.scrollTop;
|
||||||
const minutes = Math.floor(y / PIXELS_PER_MINUTE);
|
const logicalMin = Math.floor(y / PIXELS_PER_MINUTE);
|
||||||
|
|
||||||
const closest = findClosestImage(minutes);
|
const closest = findClosestImage(logicalMin);
|
||||||
if (closest) {
|
if (closest) {
|
||||||
lockedImage.value = closest;
|
lockedImage.value = closest;
|
||||||
updatePreview(closest);
|
updatePreview(closest);
|
||||||
@@ -229,13 +224,13 @@ const handleTimelineClick = (e: MouseEvent) => {
|
|||||||
>
|
>
|
||||||
<!-- Ruler Canvas -->
|
<!-- Ruler Canvas -->
|
||||||
<div :style="{ height: RULER_HEIGHT + 'px' }" class="relative ml-16">
|
<div :style="{ height: RULER_HEIGHT + 'px' }" class="relative ml-16">
|
||||||
<!-- Hour Markers -->
|
<!-- Hour Markers (Starting from 03:00) -->
|
||||||
<div v-for="h in 24" :key="h"
|
<div v-for="h in 24" :key="h"
|
||||||
class="absolute left-0 w-full border-t border-[#E5E5E7]/60 flex items-start"
|
class="absolute left-0 w-full border-t border-[#E5E5E7]/60 flex items-start"
|
||||||
:style="{ top: (h-1) * 60 * PIXELS_PER_MINUTE + 'px' }"
|
:style="{ top: (h-1) * 60 * PIXELS_PER_MINUTE + 'px' }"
|
||||||
>
|
>
|
||||||
<span class="absolute -left-12 -top-2.5 text-[10px] font-bold text-[#86868B] tracking-tighter">
|
<span class="absolute -left-12 -top-2.5 text-[10px] font-bold text-[#86868B] tracking-tighter">
|
||||||
{{ String(h-1).padStart(2, '0') }}:00
|
{{ String((h - 1 + 3) % 24).padStart(2, '0') }}:00
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -248,14 +243,14 @@ const handleTimelineClick = (e: MouseEvent) => {
|
|||||||
selectedImage?.path === img.path ? 'bg-[#007AFF]/60 h-2 shadow-sm z-10' : '',
|
selectedImage?.path === img.path ? 'bg-[#007AFF]/60 h-2 shadow-sm z-10' : '',
|
||||||
lockedImage?.path === img.path ? 'bg-[#007AFF] h-2.5 ring-2 ring-[#007AFF]/20 z-20' : ''
|
lockedImage?.path === img.path ? 'bg-[#007AFF] h-2.5 ring-2 ring-[#007AFF]/20 z-20' : ''
|
||||||
]"
|
]"
|
||||||
:style="{ top: timeToMinutes(img.time) * PIXELS_PER_MINUTE + 'px' }"
|
:style="{ top: timeToLogicalMinutes(img.time, img.isNextDay) * PIXELS_PER_MINUTE + 'px' }"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Hover Cursor Line -->
|
<!-- Hover Cursor Line -->
|
||||||
<div
|
<div
|
||||||
v-if="hoveredTime"
|
v-if="hoveredTime"
|
||||||
class="absolute left-0 right-0 border-t-2 border-[#007AFF] z-30 pointer-events-none"
|
class="absolute left-0 right-0 border-t-2 border-[#007AFF] z-30 pointer-events-none"
|
||||||
:style="{ top: timeToMinutes(hoveredTime) * PIXELS_PER_MINUTE + 'px' }"
|
:style="{ top: timeToLogicalMinutes(hoveredTime, hoveredTime < '03:00' && hoveredTime >= '00:00' && hoveredTime !== '03:00') * PIXELS_PER_MINUTE + 'px' }"
|
||||||
>
|
>
|
||||||
<div class="absolute -left-14 -top-3 bg-[#007AFF] text-white text-[10px] px-1.5 py-0.5 rounded font-bold shadow-sm">
|
<div class="absolute -left-14 -top-3 bg-[#007AFF] text-white text-[10px] px-1.5 py-0.5 rounded font-bold shadow-sm">
|
||||||
{{ hoveredTime }}
|
{{ hoveredTime }}
|
||||||
|
|||||||
Reference in New Issue
Block a user