Navigation & display logic #1
39 changed files with 2491 additions and 460 deletions
|
|
@ -1,7 +0,0 @@
|
||||||
FROM swift:noble
|
|
||||||
|
|
||||||
RUN touch /var/mail/ubuntu && \
|
|
||||||
chown ubuntu /var/mail/ubuntu && \
|
|
||||||
userdel -r ubuntu && \
|
|
||||||
apt-get update && \
|
|
||||||
apt-get install -y libadwaita-1.0 libadwaita-1-dev
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
{
|
|
||||||
"name": "Swift",
|
|
||||||
"build": {
|
|
||||||
"dockerfile": "Dockerfile"
|
|
||||||
},
|
|
||||||
"features": {
|
|
||||||
"ghcr.io/devcontainers/features/common-utils:2": {
|
|
||||||
"installZsh": "false",
|
|
||||||
"username": "vscode",
|
|
||||||
"userUid": "1000",
|
|
||||||
"userGid": "1000",
|
|
||||||
"upgradePackages": "false"
|
|
||||||
},
|
|
||||||
"ghcr.io/devcontainers/features/git:1": {
|
|
||||||
"version": "os-provided",
|
|
||||||
"ppa": "false"
|
|
||||||
},
|
|
||||||
"ghcr.io/devcontainers-contrib/features/curl-apt-get:1": {}
|
|
||||||
},
|
|
||||||
"runArgs": [
|
|
||||||
"--cap-add=SYS_PTRACE",
|
|
||||||
"--security-opt",
|
|
||||||
"seccomp=unconfined"
|
|
||||||
],
|
|
||||||
// Configure tool-specific properties.
|
|
||||||
"customizations": {
|
|
||||||
// Configure properties specific to VS Code.
|
|
||||||
"vscode": {
|
|
||||||
// Set *default* container specific settings.json values on container create.
|
|
||||||
"settings": {
|
|
||||||
"lldb.library": "/usr/lib/liblldb.so"
|
|
||||||
},
|
|
||||||
// Add the IDs of extensions you want installed when the container is created.
|
|
||||||
"extensions": [
|
|
||||||
"sswg.swift-lang"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
|
||||||
// "forwardPorts": [],
|
|
||||||
|
|
||||||
// Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
|
||||||
"remoteUser": "vscode"
|
|
||||||
}
|
|
||||||
|
|
@ -13,6 +13,7 @@ let package = Package(
|
||||||
.package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"),
|
.package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.0.0"),
|
||||||
.package(url: "https://github.com/stephencelis/SQLite.swift", from: "0.16.0"),
|
.package(url: "https://github.com/stephencelis/SQLite.swift", from: "0.16.0"),
|
||||||
.package(url: "https://github.com/apple/swift-syntax", from: "603.0.0"),
|
.package(url: "https://github.com/apple/swift-syntax", from: "603.0.0"),
|
||||||
|
.package(url: "https://github.com/apple/swift-log", from: "1.6.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
|
|
@ -48,11 +49,20 @@ let package = Package(
|
||||||
dependencies: [
|
dependencies: [
|
||||||
"LuminateCore",
|
"LuminateCore",
|
||||||
.product(name: "Adwaita", package: "adwaita-swift"),
|
.product(name: "Adwaita", package: "adwaita-swift"),
|
||||||
|
.product(name: "Logging", package: "swift-log"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
.target(
|
||||||
|
name: "CGtkWidgets",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "CAdw", package: "adwaita-swift")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
|
||||||
.target(
|
.target(
|
||||||
name: "LuminateUI",
|
name: "LuminateUI",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
"CGtkWidgets",
|
||||||
"LuminateCore",
|
"LuminateCore",
|
||||||
"LuminateDI",
|
"LuminateDI",
|
||||||
"LuminateObservationMacros",
|
"LuminateObservationMacros",
|
||||||
|
|
@ -79,11 +89,21 @@ let package = Package(
|
||||||
"LuminateObservationMacros",
|
"LuminateObservationMacros",
|
||||||
.product(name: "Adwaita", package: "adwaita-swift"),
|
.product(name: "Adwaita", package: "adwaita-swift"),
|
||||||
.product(name: "Localized", package: "localized"),
|
.product(name: "Localized", package: "localized"),
|
||||||
|
.product(name: "Logging", package: "swift-log"),
|
||||||
],
|
],
|
||||||
path: "Sources/Luminate",
|
path: "Sources/Luminate",
|
||||||
resources: [.process("Localized.yml")],
|
resources: [.process("Localized.yml")],
|
||||||
plugins: [.plugin(name: "GenerateLocalized", package: "localized")]
|
plugins: [.plugin(name: "GenerateLocalized", package: "localized")]
|
||||||
),
|
),
|
||||||
|
|
||||||
|
.testTarget(
|
||||||
|
name: "LuminateTests",
|
||||||
|
dependencies: [
|
||||||
|
"CGtkWidgets",
|
||||||
|
.product(name: "CAdw", package: "adwaita-swift"),
|
||||||
|
.product(name: "Adwaita", package: "adwaita-swift"),
|
||||||
|
]
|
||||||
|
),
|
||||||
],
|
],
|
||||||
swiftLanguageModes: [.v5]
|
swiftLanguageModes: [.v5]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
251
Sources/CGtkWidgets/aspect_container.c
Normal file
251
Sources/CGtkWidgets/aspect_container.c
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
#include "aspect_container.h"
|
||||||
|
|
||||||
|
struct _AspectContainer {
|
||||||
|
GtkWidget parent_instance;
|
||||||
|
float aspect_ratio;
|
||||||
|
int max_width;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum {
|
||||||
|
PROP_0,
|
||||||
|
PROP_ASPECT_RATIO,
|
||||||
|
PROP_MAX_WIDTH,
|
||||||
|
LAST_PROP
|
||||||
|
};
|
||||||
|
|
||||||
|
static GParamSpec *props[LAST_PROP];
|
||||||
|
|
||||||
|
G_DEFINE_TYPE(AspectContainer, aspect_container, GTK_TYPE_WIDGET)
|
||||||
|
|
||||||
|
static void
|
||||||
|
aspect_container_set_property(GObject *object, guint prop_id,
|
||||||
|
const GValue *value, GParamSpec *pspec)
|
||||||
|
{
|
||||||
|
AspectContainer *self = ASPECT_CONTAINER(object);
|
||||||
|
switch (prop_id) {
|
||||||
|
case PROP_ASPECT_RATIO:
|
||||||
|
aspect_container_set_aspect_ratio(self, g_value_get_float(value));
|
||||||
|
break;
|
||||||
|
case PROP_MAX_WIDTH:
|
||||||
|
aspect_container_set_max_width(self, g_value_get_int(value));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
aspect_container_get_property(GObject *object, guint prop_id,
|
||||||
|
GValue *value, GParamSpec *pspec)
|
||||||
|
{
|
||||||
|
AspectContainer *self = ASPECT_CONTAINER(object);
|
||||||
|
switch (prop_id) {
|
||||||
|
case PROP_ASPECT_RATIO:
|
||||||
|
g_value_set_float(value, self->aspect_ratio);
|
||||||
|
break;
|
||||||
|
case PROP_MAX_WIDTH:
|
||||||
|
g_value_set_int(value, self->max_width);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
aspect_container_dispose(GObject *object)
|
||||||
|
{
|
||||||
|
GtkWidget *widget = GTK_WIDGET(object);
|
||||||
|
GtkWidget *child;
|
||||||
|
|
||||||
|
while ((child = gtk_widget_get_first_child(widget)))
|
||||||
|
gtk_widget_unparent(child);
|
||||||
|
|
||||||
|
G_OBJECT_CLASS(aspect_container_parent_class)->dispose(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
static GtkSizeRequestMode
|
||||||
|
aspect_container_get_request_mode(GtkWidget *widget)
|
||||||
|
{
|
||||||
|
(void)widget;
|
||||||
|
return GTK_SIZE_REQUEST_HEIGHT_FOR_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
aspect_container_size_allocate(GtkWidget *widget,
|
||||||
|
int width, int height, int baseline)
|
||||||
|
{
|
||||||
|
GtkWidget *child = gtk_widget_get_first_child(widget);
|
||||||
|
if (!child)
|
||||||
|
return;
|
||||||
|
|
||||||
|
GtkAllocation alloc = {
|
||||||
|
.x = 0,
|
||||||
|
.y = 0,
|
||||||
|
.width = width,
|
||||||
|
.height = height,
|
||||||
|
};
|
||||||
|
gtk_widget_size_allocate(child, &alloc, baseline);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
aspect_container_measure(GtkWidget *widget,
|
||||||
|
GtkOrientation orientation,
|
||||||
|
int for_size,
|
||||||
|
int *minimum,
|
||||||
|
int *natural,
|
||||||
|
int *minimum_baseline,
|
||||||
|
int *natural_baseline)
|
||||||
|
{
|
||||||
|
AspectContainer *self = ASPECT_CONTAINER(widget);
|
||||||
|
|
||||||
|
if (minimum_baseline)
|
||||||
|
*minimum_baseline = -1;
|
||||||
|
if (natural_baseline)
|
||||||
|
*natural_baseline = -1;
|
||||||
|
|
||||||
|
GtkWidget *child = gtk_widget_get_first_child(widget);
|
||||||
|
gboolean has_child = child && gtk_widget_get_visible(child);
|
||||||
|
|
||||||
|
if (orientation == GTK_ORIENTATION_HORIZONTAL) {
|
||||||
|
if (has_child)
|
||||||
|
gtk_widget_measure(child, GTK_ORIENTATION_HORIZONTAL, -1,
|
||||||
|
NULL, NULL, NULL, NULL);
|
||||||
|
|
||||||
|
int w = self->max_width > 0 ? self->max_width : 0;
|
||||||
|
if (minimum) *minimum = w;
|
||||||
|
if (natural) *natural = w;
|
||||||
|
} else {
|
||||||
|
if (for_size >= 0) {
|
||||||
|
int w = self->max_width > 0 && for_size > self->max_width
|
||||||
|
? self->max_width : for_size;
|
||||||
|
int h_min = (int)(w * self->aspect_ratio);
|
||||||
|
int h_nat = (int)(w * self->aspect_ratio);
|
||||||
|
if (minimum) *minimum = h_min;
|
||||||
|
if (natural) *natural = h_nat;
|
||||||
|
} else if (has_child) {
|
||||||
|
int child_min_w = 0, child_nat_w = 0;
|
||||||
|
gtk_widget_measure(child, GTK_ORIENTATION_HORIZONTAL, -1,
|
||||||
|
&child_min_w, &child_nat_w, NULL, NULL);
|
||||||
|
|
||||||
|
int min_w = child_min_w;
|
||||||
|
int nat_w = child_nat_w >= child_min_w ? child_nat_w : child_min_w;
|
||||||
|
|
||||||
|
int req_w = 0, req_h = 0;
|
||||||
|
gtk_widget_get_size_request(widget, &req_w, &req_h);
|
||||||
|
if (req_w > min_w) min_w = req_w;
|
||||||
|
if (req_w > nat_w) nat_w = req_w;
|
||||||
|
|
||||||
|
if (self->max_width > 0) {
|
||||||
|
if (min_w > self->max_width) min_w = self->max_width;
|
||||||
|
if (nat_w > self->max_width) nat_w = self->max_width;
|
||||||
|
}
|
||||||
|
|
||||||
|
int h_min = (int)(min_w * self->aspect_ratio);
|
||||||
|
int h_nat = (int)(nat_w * self->aspect_ratio);
|
||||||
|
if (minimum) *minimum = h_min;
|
||||||
|
if (natural) *natural = h_nat;
|
||||||
|
} else {
|
||||||
|
if (minimum) *minimum = 0;
|
||||||
|
if (natural) *natural = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
aspect_container_compute_expand(GtkWidget *widget,
|
||||||
|
gboolean *hexpand_p,
|
||||||
|
gboolean *vexpand_p)
|
||||||
|
{
|
||||||
|
GtkWidget *child = gtk_widget_get_first_child(widget);
|
||||||
|
if (child) {
|
||||||
|
*hexpand_p = gtk_widget_compute_expand(child, GTK_ORIENTATION_HORIZONTAL);
|
||||||
|
*vexpand_p = gtk_widget_compute_expand(child, GTK_ORIENTATION_VERTICAL);
|
||||||
|
} else {
|
||||||
|
*hexpand_p = FALSE;
|
||||||
|
*vexpand_p = FALSE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
aspect_container_class_init(AspectContainerClass *klass)
|
||||||
|
{
|
||||||
|
GObjectClass *gobject_class = G_OBJECT_CLASS(klass);
|
||||||
|
GtkWidgetClass *widget_class = GTK_WIDGET_CLASS(klass);
|
||||||
|
|
||||||
|
gobject_class->set_property = aspect_container_set_property;
|
||||||
|
gobject_class->get_property = aspect_container_get_property;
|
||||||
|
gobject_class->dispose = aspect_container_dispose;
|
||||||
|
|
||||||
|
widget_class->get_request_mode = aspect_container_get_request_mode;
|
||||||
|
widget_class->size_allocate = aspect_container_size_allocate;
|
||||||
|
widget_class->measure = aspect_container_measure;
|
||||||
|
widget_class->compute_expand = aspect_container_compute_expand;
|
||||||
|
|
||||||
|
props[PROP_ASPECT_RATIO] = g_param_spec_float(
|
||||||
|
"aspect-ratio", NULL, NULL,
|
||||||
|
0.0, G_MAXFLOAT, 1.0,
|
||||||
|
G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY);
|
||||||
|
|
||||||
|
props[PROP_MAX_WIDTH] = g_param_spec_int(
|
||||||
|
"max-width", NULL, NULL,
|
||||||
|
0, G_MAXINT, 0,
|
||||||
|
G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY);
|
||||||
|
|
||||||
|
g_object_class_install_properties(gobject_class, LAST_PROP, props);
|
||||||
|
|
||||||
|
gtk_widget_class_set_css_name(widget_class, "aspectcontainer");
|
||||||
|
gtk_widget_class_set_accessible_role(widget_class, GTK_ACCESSIBLE_ROLE_GENERIC);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
aspect_container_init(AspectContainer *self)
|
||||||
|
{
|
||||||
|
self->aspect_ratio = 1.0;
|
||||||
|
self->max_width = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget *
|
||||||
|
aspect_container_new(float aspect_ratio)
|
||||||
|
{
|
||||||
|
return g_object_new(ASPECT_TYPE_CONTAINER,
|
||||||
|
"aspect-ratio", aspect_ratio,
|
||||||
|
NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
aspect_container_set_aspect_ratio(AspectContainer *self, float ratio)
|
||||||
|
{
|
||||||
|
g_return_if_fail(ASPECT_IS_CONTAINER(self));
|
||||||
|
if (self->aspect_ratio == ratio)
|
||||||
|
return;
|
||||||
|
self->aspect_ratio = ratio;
|
||||||
|
g_object_notify_by_pspec(G_OBJECT(self), props[PROP_ASPECT_RATIO]);
|
||||||
|
gtk_widget_queue_resize(GTK_WIDGET(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
float
|
||||||
|
aspect_container_get_aspect_ratio(AspectContainer *self)
|
||||||
|
{
|
||||||
|
g_return_val_if_fail(ASPECT_IS_CONTAINER(self), 1.0);
|
||||||
|
return self->aspect_ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
aspect_container_set_max_width(AspectContainer *self, int max_width)
|
||||||
|
{
|
||||||
|
g_return_if_fail(ASPECT_IS_CONTAINER(self));
|
||||||
|
if (self->max_width == max_width)
|
||||||
|
return;
|
||||||
|
self->max_width = max_width;
|
||||||
|
g_object_notify_by_pspec(G_OBJECT(self), props[PROP_MAX_WIDTH]);
|
||||||
|
gtk_widget_queue_resize(GTK_WIDGET(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
int
|
||||||
|
aspect_container_get_max_width(AspectContainer *self)
|
||||||
|
{
|
||||||
|
g_return_val_if_fail(ASPECT_IS_CONTAINER(self), 0);
|
||||||
|
return self->max_width;
|
||||||
|
}
|
||||||
431
Sources/CGtkWidgets/flow_grid.c
Normal file
431
Sources/CGtkWidgets/flow_grid.c
Normal file
|
|
@ -0,0 +1,431 @@
|
||||||
|
#include "flow_grid.h"
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
|
struct _FlowGrid {
|
||||||
|
GtkWidget parent_instance;
|
||||||
|
int minimum_size;
|
||||||
|
int column_spacing;
|
||||||
|
int row_spacing;
|
||||||
|
FlowGridJustify justify;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum {
|
||||||
|
PROP_0,
|
||||||
|
PROP_MINIMUM_SIZE,
|
||||||
|
PROP_COLUMN_SPACING,
|
||||||
|
PROP_ROW_SPACING,
|
||||||
|
PROP_JUSTIFY,
|
||||||
|
LAST_PROP
|
||||||
|
};
|
||||||
|
|
||||||
|
static GParamSpec *props[LAST_PROP];
|
||||||
|
|
||||||
|
G_DEFINE_TYPE(FlowGrid, flow_grid, GTK_TYPE_WIDGET)
|
||||||
|
|
||||||
|
static void
|
||||||
|
flow_grid_set_property(GObject *object, guint prop_id,
|
||||||
|
const GValue *value, GParamSpec *pspec)
|
||||||
|
{
|
||||||
|
FlowGrid *self = FLOW_GRID(object);
|
||||||
|
switch (prop_id) {
|
||||||
|
case PROP_MINIMUM_SIZE:
|
||||||
|
flow_grid_set_minimum_size(self, g_value_get_int(value));
|
||||||
|
break;
|
||||||
|
case PROP_COLUMN_SPACING:
|
||||||
|
flow_grid_set_column_spacing(self, g_value_get_int(value));
|
||||||
|
break;
|
||||||
|
case PROP_ROW_SPACING:
|
||||||
|
flow_grid_set_row_spacing(self, g_value_get_int(value));
|
||||||
|
break;
|
||||||
|
case PROP_JUSTIFY:
|
||||||
|
flow_grid_set_justify(self, (FlowGridJustify)g_value_get_int(value));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
flow_grid_get_property(GObject *object, guint prop_id,
|
||||||
|
GValue *value, GParamSpec *pspec)
|
||||||
|
{
|
||||||
|
FlowGrid *self = FLOW_GRID(object);
|
||||||
|
switch (prop_id) {
|
||||||
|
case PROP_MINIMUM_SIZE:
|
||||||
|
g_value_set_int(value, self->minimum_size);
|
||||||
|
break;
|
||||||
|
case PROP_COLUMN_SPACING:
|
||||||
|
g_value_set_int(value, self->column_spacing);
|
||||||
|
break;
|
||||||
|
case PROP_ROW_SPACING:
|
||||||
|
g_value_set_int(value, self->row_spacing);
|
||||||
|
break;
|
||||||
|
case PROP_JUSTIFY:
|
||||||
|
g_value_set_int(value, self->justify);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
flow_grid_dispose(GObject *object)
|
||||||
|
{
|
||||||
|
GtkWidget *widget = GTK_WIDGET(object);
|
||||||
|
GtkWidget *child;
|
||||||
|
while ((child = gtk_widget_get_first_child(widget)))
|
||||||
|
gtk_widget_unparent(child);
|
||||||
|
G_OBJECT_CLASS(flow_grid_parent_class)->dispose(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int
|
||||||
|
flow_grid_count_visible_children(GtkWidget *widget)
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
GtkWidget *child;
|
||||||
|
for (child = gtk_widget_get_first_child(widget);
|
||||||
|
child != NULL;
|
||||||
|
child = gtk_widget_get_next_sibling(child)) {
|
||||||
|
if (gtk_widget_should_layout(child))
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
flow_grid_collect_visible_children(GtkWidget *widget, GtkWidget **children, int max)
|
||||||
|
{
|
||||||
|
int i = 0;
|
||||||
|
GtkWidget *child;
|
||||||
|
for (child = gtk_widget_get_first_child(widget);
|
||||||
|
child != NULL && i < max;
|
||||||
|
child = gtk_widget_get_next_sibling(child)) {
|
||||||
|
if (gtk_widget_should_layout(child))
|
||||||
|
children[i++] = child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
flow_grid_compute_layout(GtkWidget *widget, int for_size,
|
||||||
|
int *out_num_columns, int *out_num_rows,
|
||||||
|
double *out_column_width)
|
||||||
|
{
|
||||||
|
FlowGrid *self = FLOW_GRID(widget);
|
||||||
|
int n_children = flow_grid_count_visible_children(widget);
|
||||||
|
|
||||||
|
if (n_children == 0) {
|
||||||
|
*out_num_columns = 1;
|
||||||
|
*out_num_rows = 0;
|
||||||
|
*out_column_width = (double)self->minimum_size;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int num_columns = (for_size + self->column_spacing)
|
||||||
|
/ (self->minimum_size + self->column_spacing);
|
||||||
|
num_columns = num_columns < 1 ? 1 : num_columns;
|
||||||
|
num_columns = num_columns > n_children ? n_children : num_columns;
|
||||||
|
|
||||||
|
double column_width;
|
||||||
|
if (n_children == num_columns)
|
||||||
|
column_width = (double)self->minimum_size;
|
||||||
|
else {
|
||||||
|
column_width = (for_size - self->column_spacing * (num_columns - 1))
|
||||||
|
/ (double)num_columns;
|
||||||
|
if (column_width < self->minimum_size)
|
||||||
|
column_width = self->minimum_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
int num_rows = (int)ceil((double)n_children / num_columns);
|
||||||
|
|
||||||
|
*out_num_columns = num_columns;
|
||||||
|
*out_num_rows = num_rows;
|
||||||
|
*out_column_width = column_width;
|
||||||
|
}
|
||||||
|
|
||||||
|
static GtkSizeRequestMode
|
||||||
|
flow_grid_get_request_mode(GtkWidget *widget)
|
||||||
|
{
|
||||||
|
return GTK_SIZE_REQUEST_HEIGHT_FOR_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
flow_grid_measure(GtkWidget *widget,
|
||||||
|
GtkOrientation orientation,
|
||||||
|
int for_size,
|
||||||
|
int *minimum,
|
||||||
|
int *natural,
|
||||||
|
int *minimum_baseline,
|
||||||
|
int *natural_baseline)
|
||||||
|
{
|
||||||
|
FlowGrid *self = FLOW_GRID(widget);
|
||||||
|
|
||||||
|
if (minimum_baseline)
|
||||||
|
*minimum_baseline = -1;
|
||||||
|
if (natural_baseline)
|
||||||
|
*natural_baseline = -1;
|
||||||
|
|
||||||
|
if (for_size <= 0) {
|
||||||
|
if (minimum) *minimum = 0;
|
||||||
|
if (natural) *natural = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int n_children = flow_grid_count_visible_children(widget);
|
||||||
|
if (n_children == 0) {
|
||||||
|
if (minimum) *minimum = 0;
|
||||||
|
if (natural) *natural = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget **children = g_newa(GtkWidget*, n_children);
|
||||||
|
flow_grid_collect_visible_children(widget, children, n_children);
|
||||||
|
|
||||||
|
int num_columns, num_rows;
|
||||||
|
double column_width;
|
||||||
|
flow_grid_compute_layout(widget, for_size, &num_columns, &num_rows, &column_width);
|
||||||
|
|
||||||
|
int *min_row_heights = g_newa(int, num_rows);
|
||||||
|
int *nat_row_heights = g_newa(int, num_rows);
|
||||||
|
memset(min_row_heights, 0, num_rows * sizeof(int));
|
||||||
|
memset(nat_row_heights, 0, num_rows * sizeof(int));
|
||||||
|
|
||||||
|
for (int i = 0; i < n_children; i++) {
|
||||||
|
int cw = (int)(column_width + 0.5);
|
||||||
|
if (cw < 0) cw = -1;
|
||||||
|
|
||||||
|
int child_min = 0, child_nat = 0;
|
||||||
|
gtk_widget_measure(children[i], orientation, cw,
|
||||||
|
&child_min, &child_nat, NULL, NULL);
|
||||||
|
int row = i / num_columns;
|
||||||
|
if (child_min > min_row_heights[row])
|
||||||
|
min_row_heights[row] = child_min;
|
||||||
|
if (child_nat > nat_row_heights[row])
|
||||||
|
nat_row_heights[row] = child_nat;
|
||||||
|
}
|
||||||
|
|
||||||
|
int spacing = self->row_spacing * (num_rows - 1);
|
||||||
|
int total_min = 0, total_nat = 0;
|
||||||
|
for (int r = 0; r < num_rows; r++) {
|
||||||
|
total_min += min_row_heights[r];
|
||||||
|
total_nat += nat_row_heights[r];
|
||||||
|
}
|
||||||
|
total_min += spacing;
|
||||||
|
total_nat += spacing;
|
||||||
|
|
||||||
|
if (minimum) *minimum = total_min;
|
||||||
|
if (natural) *natural = total_nat > total_min ? total_nat : total_min;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
flow_grid_size_allocate(GtkWidget *widget,
|
||||||
|
int width, int height, int baseline)
|
||||||
|
{
|
||||||
|
FlowGrid *self = FLOW_GRID(widget);
|
||||||
|
|
||||||
|
int n_children = flow_grid_count_visible_children(widget);
|
||||||
|
if (n_children == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
GtkWidget **children = g_newa(GtkWidget*, n_children);
|
||||||
|
flow_grid_collect_visible_children(widget, children, n_children);
|
||||||
|
|
||||||
|
int num_columns, num_rows;
|
||||||
|
double column_width;
|
||||||
|
flow_grid_compute_layout(widget, width, &num_columns, &num_rows, &column_width);
|
||||||
|
|
||||||
|
int *row_heights = g_newa(int, num_rows);
|
||||||
|
memset(row_heights, 0, num_rows * sizeof(int));
|
||||||
|
|
||||||
|
for (int i = 0; i < n_children; i++) {
|
||||||
|
int cw = (int)(column_width + 0.5);
|
||||||
|
if (cw < 0) cw = -1;
|
||||||
|
|
||||||
|
int child_min = 0;
|
||||||
|
gtk_widget_measure(children[i], GTK_ORIENTATION_VERTICAL, cw,
|
||||||
|
&child_min, NULL, NULL, NULL);
|
||||||
|
int row = i / num_columns;
|
||||||
|
if (child_min > row_heights[row])
|
||||||
|
row_heights[row] = child_min;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < n_children; i++) {
|
||||||
|
int idx = i;
|
||||||
|
int column = idx % num_columns;
|
||||||
|
int row = idx / num_columns;
|
||||||
|
|
||||||
|
int x_offset = 0;
|
||||||
|
if (row + 1 == num_rows) {
|
||||||
|
int n_in_last_row = n_children % num_columns;
|
||||||
|
if (n_in_last_row == 0)
|
||||||
|
n_in_last_row = num_columns;
|
||||||
|
double row_width = column_width * n_in_last_row
|
||||||
|
+ self->column_spacing * (n_in_last_row - 1);
|
||||||
|
double empty_space = width - row_width;
|
||||||
|
switch (self->justify) {
|
||||||
|
case FLOW_GRID_JUSTIFY_CENTER:
|
||||||
|
x_offset = (int)(empty_space / 2.0 + 0.5);
|
||||||
|
break;
|
||||||
|
case FLOW_GRID_JUSTIFY_END:
|
||||||
|
x_offset = (int)(empty_space + 0.5);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
x_offset = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int y_offset = 0;
|
||||||
|
for (int r = 0; r < row; r++)
|
||||||
|
y_offset += row_heights[r];
|
||||||
|
|
||||||
|
int x = x_offset
|
||||||
|
+ (int)(column * column_width + 0.5)
|
||||||
|
+ column * self->column_spacing;
|
||||||
|
int y = y_offset + row * self->row_spacing;
|
||||||
|
|
||||||
|
int child_w = (int)(column_width + 0.5);
|
||||||
|
int child_h = row_heights[row];
|
||||||
|
|
||||||
|
GtkAllocation alloc = {
|
||||||
|
.x = x,
|
||||||
|
.y = y,
|
||||||
|
.width = child_w,
|
||||||
|
.height = child_h,
|
||||||
|
};
|
||||||
|
gtk_widget_size_allocate(children[i], &alloc, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
flow_grid_class_init(FlowGridClass *klass)
|
||||||
|
{
|
||||||
|
GObjectClass *gobject_class = G_OBJECT_CLASS(klass);
|
||||||
|
GtkWidgetClass *widget_class = GTK_WIDGET_CLASS(klass);
|
||||||
|
|
||||||
|
gobject_class->set_property = flow_grid_set_property;
|
||||||
|
gobject_class->get_property = flow_grid_get_property;
|
||||||
|
gobject_class->dispose = flow_grid_dispose;
|
||||||
|
|
||||||
|
widget_class->get_request_mode = flow_grid_get_request_mode;
|
||||||
|
widget_class->measure = flow_grid_measure;
|
||||||
|
widget_class->size_allocate = flow_grid_size_allocate;
|
||||||
|
|
||||||
|
props[PROP_MINIMUM_SIZE] = g_param_spec_int(
|
||||||
|
"minimum-size", NULL, NULL,
|
||||||
|
1, G_MAXINT, 200,
|
||||||
|
G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY);
|
||||||
|
|
||||||
|
props[PROP_COLUMN_SPACING] = g_param_spec_int(
|
||||||
|
"column-spacing", NULL, NULL,
|
||||||
|
0, G_MAXINT, 0,
|
||||||
|
G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY);
|
||||||
|
|
||||||
|
props[PROP_ROW_SPACING] = g_param_spec_int(
|
||||||
|
"row-spacing", NULL, NULL,
|
||||||
|
0, G_MAXINT, 0,
|
||||||
|
G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY);
|
||||||
|
|
||||||
|
props[PROP_JUSTIFY] = g_param_spec_int(
|
||||||
|
"justify", NULL, NULL,
|
||||||
|
0, 2, 0,
|
||||||
|
G_PARAM_READWRITE | G_PARAM_EXPLICIT_NOTIFY);
|
||||||
|
|
||||||
|
g_object_class_install_properties(gobject_class, LAST_PROP, props);
|
||||||
|
|
||||||
|
gtk_widget_class_set_css_name(widget_class, "flowgrid");
|
||||||
|
gtk_widget_class_set_accessible_role(widget_class, GTK_ACCESSIBLE_ROLE_GROUP);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void
|
||||||
|
flow_grid_init(FlowGrid *self)
|
||||||
|
{
|
||||||
|
self->minimum_size = 200;
|
||||||
|
self->column_spacing = 0;
|
||||||
|
self->row_spacing = 0;
|
||||||
|
self->justify = FLOW_GRID_JUSTIFY_START;
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget *
|
||||||
|
flow_grid_new(int minimum_size, int column_spacing, int row_spacing)
|
||||||
|
{
|
||||||
|
return g_object_new(FLOW_TYPE_GRID,
|
||||||
|
"minimum-size", minimum_size,
|
||||||
|
"column-spacing", column_spacing,
|
||||||
|
"row-spacing", row_spacing,
|
||||||
|
NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
flow_grid_set_minimum_size(FlowGrid *self, int size)
|
||||||
|
{
|
||||||
|
g_return_if_fail(FLOW_IS_GRID(self));
|
||||||
|
if (self->minimum_size == size)
|
||||||
|
return;
|
||||||
|
self->minimum_size = size;
|
||||||
|
g_object_notify_by_pspec(G_OBJECT(self), props[PROP_MINIMUM_SIZE]);
|
||||||
|
gtk_widget_queue_resize(GTK_WIDGET(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
int
|
||||||
|
flow_grid_get_minimum_size(FlowGrid *self)
|
||||||
|
{
|
||||||
|
g_return_val_if_fail(FLOW_IS_GRID(self), 200);
|
||||||
|
return self->minimum_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
flow_grid_set_column_spacing(FlowGrid *self, int spacing)
|
||||||
|
{
|
||||||
|
g_return_if_fail(FLOW_IS_GRID(self));
|
||||||
|
if (self->column_spacing == spacing)
|
||||||
|
return;
|
||||||
|
self->column_spacing = spacing;
|
||||||
|
g_object_notify_by_pspec(G_OBJECT(self), props[PROP_COLUMN_SPACING]);
|
||||||
|
gtk_widget_queue_resize(GTK_WIDGET(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
int
|
||||||
|
flow_grid_get_column_spacing(FlowGrid *self)
|
||||||
|
{
|
||||||
|
g_return_val_if_fail(FLOW_IS_GRID(self), 0);
|
||||||
|
return self->column_spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
flow_grid_set_row_spacing(FlowGrid *self, int spacing)
|
||||||
|
{
|
||||||
|
g_return_if_fail(FLOW_IS_GRID(self));
|
||||||
|
if (self->row_spacing == spacing)
|
||||||
|
return;
|
||||||
|
self->row_spacing = spacing;
|
||||||
|
g_object_notify_by_pspec(G_OBJECT(self), props[PROP_ROW_SPACING]);
|
||||||
|
gtk_widget_queue_resize(GTK_WIDGET(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
int
|
||||||
|
flow_grid_get_row_spacing(FlowGrid *self)
|
||||||
|
{
|
||||||
|
g_return_val_if_fail(FLOW_IS_GRID(self), 0);
|
||||||
|
return self->row_spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
flow_grid_set_justify(FlowGrid *self, FlowGridJustify justify)
|
||||||
|
{
|
||||||
|
g_return_if_fail(FLOW_IS_GRID(self));
|
||||||
|
if (self->justify == justify)
|
||||||
|
return;
|
||||||
|
self->justify = justify;
|
||||||
|
g_object_notify_by_pspec(G_OBJECT(self), props[PROP_JUSTIFY]);
|
||||||
|
gtk_widget_queue_resize(GTK_WIDGET(self));
|
||||||
|
}
|
||||||
|
|
||||||
|
FlowGridJustify
|
||||||
|
flow_grid_get_justify(FlowGrid *self)
|
||||||
|
{
|
||||||
|
g_return_val_if_fail(FLOW_IS_GRID(self), FLOW_GRID_JUSTIFY_START);
|
||||||
|
return self->justify;
|
||||||
|
}
|
||||||
31
Sources/CGtkWidgets/include/aspect_container.h
Normal file
31
Sources/CGtkWidgets/include/aspect_container.h
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#ifndef ASPECT_CONTAINER_H
|
||||||
|
#define ASPECT_CONTAINER_H
|
||||||
|
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
G_BEGIN_DECLS
|
||||||
|
|
||||||
|
#define ASPECT_TYPE_CONTAINER (aspect_container_get_type())
|
||||||
|
#define ASPECT_CONTAINER(obj) (G_TYPE_CHECK_INSTANCE_CAST((obj), ASPECT_TYPE_CONTAINER, AspectContainer))
|
||||||
|
#define ASPECT_IS_CONTAINER(obj) (G_TYPE_CHECK_INSTANCE_TYPE((obj), ASPECT_TYPE_CONTAINER))
|
||||||
|
|
||||||
|
typedef struct _AspectContainer AspectContainer;
|
||||||
|
typedef struct _AspectContainerClass AspectContainerClass;
|
||||||
|
|
||||||
|
struct _AspectContainerClass {
|
||||||
|
GtkWidgetClass parent_class;
|
||||||
|
};
|
||||||
|
|
||||||
|
GType aspect_container_get_type (void);
|
||||||
|
GtkWidget *aspect_container_new (float aspect_ratio);
|
||||||
|
|
||||||
|
void aspect_container_set_aspect_ratio (AspectContainer *self,
|
||||||
|
float ratio);
|
||||||
|
float aspect_container_get_aspect_ratio (AspectContainer *self);
|
||||||
|
void aspect_container_set_max_width (AspectContainer *self,
|
||||||
|
int max_width);
|
||||||
|
int aspect_container_get_max_width (AspectContainer *self);
|
||||||
|
|
||||||
|
G_END_DECLS
|
||||||
|
|
||||||
|
#endif /* ASPECT_CONTAINER_H */
|
||||||
41
Sources/CGtkWidgets/include/flow_grid.h
Normal file
41
Sources/CGtkWidgets/include/flow_grid.h
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
#ifndef FLOW_GRID_H
|
||||||
|
#define FLOW_GRID_H
|
||||||
|
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
G_BEGIN_DECLS
|
||||||
|
|
||||||
|
#define FLOW_TYPE_GRID (flow_grid_get_type())
|
||||||
|
#define FLOW_GRID(obj) (G_TYPE_CHECK_INSTANCE_CAST((obj), FLOW_TYPE_GRID, FlowGrid))
|
||||||
|
#define FLOW_IS_GRID(obj) (G_TYPE_CHECK_INSTANCE_TYPE((obj), FLOW_TYPE_GRID))
|
||||||
|
|
||||||
|
typedef struct _FlowGrid FlowGrid;
|
||||||
|
typedef struct _FlowGridClass FlowGridClass;
|
||||||
|
|
||||||
|
struct _FlowGridClass {
|
||||||
|
GtkWidgetClass parent_class;
|
||||||
|
};
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
FLOW_GRID_JUSTIFY_START,
|
||||||
|
FLOW_GRID_JUSTIFY_CENTER,
|
||||||
|
FLOW_GRID_JUSTIFY_END,
|
||||||
|
} FlowGridJustify;
|
||||||
|
|
||||||
|
GType flow_grid_get_type (void);
|
||||||
|
GtkWidget *flow_grid_new (int minimum_size,
|
||||||
|
int column_spacing,
|
||||||
|
int row_spacing);
|
||||||
|
|
||||||
|
void flow_grid_set_minimum_size (FlowGrid *self, int size);
|
||||||
|
int flow_grid_get_minimum_size (FlowGrid *self);
|
||||||
|
void flow_grid_set_column_spacing (FlowGrid *self, int spacing);
|
||||||
|
int flow_grid_get_column_spacing (FlowGrid *self);
|
||||||
|
void flow_grid_set_row_spacing (FlowGrid *self, int spacing);
|
||||||
|
int flow_grid_get_row_spacing (FlowGrid *self);
|
||||||
|
void flow_grid_set_justify (FlowGrid *self, FlowGridJustify justify);
|
||||||
|
FlowGridJustify flow_grid_get_justify (FlowGrid *self);
|
||||||
|
|
||||||
|
G_END_DECLS
|
||||||
|
|
||||||
|
#endif /* FLOW_GRID_H */
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
|
|
||||||
import Adwaita
|
import Adwaita
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Logging
|
||||||
import LuminateCore
|
import LuminateCore
|
||||||
import LuminateDI
|
import LuminateDI
|
||||||
import LuminatePlayer
|
import LuminatePlayer
|
||||||
|
|
@ -35,12 +36,22 @@ struct Luminate: App {
|
||||||
@State private var isLaunchLoading = true
|
@State private var isLaunchLoading = true
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
LoggingSystem.bootstrap { label in
|
||||||
|
var handler = StreamLogHandler.standardOutput(label: label)
|
||||||
|
#if DEBUG
|
||||||
|
handler.logLevel = .debug
|
||||||
|
#else
|
||||||
|
handler.logLevel = .info
|
||||||
|
#endif
|
||||||
|
return handler
|
||||||
|
}
|
||||||
ObservationRegistrar.onChange = { StateManager.updateViews() }
|
ObservationRegistrar.onChange = { StateManager.updateViews() }
|
||||||
if let store = try? SQLiteStore(dbURL: SQLiteStore.defaultDatabaseURL()) {
|
if let store = try? SQLiteStore(dbURL: SQLiteStore.defaultDatabaseURL()) {
|
||||||
DIContainer.shared.register(\.persistence, value: store)
|
DIContainer.shared.register(\.persistence, value: store)
|
||||||
}
|
}
|
||||||
DIContainer.shared.register(\.imageService, value: ImageService())
|
DIContainer.shared.register(\.imageService, value: ImageService())
|
||||||
DIContainer.shared.register(\.pageAnimationTracker, value: PageAnimationTracker())
|
DIContainer.shared.register(\.viewUpdateScheduler, value: ViewUpdateScheduler())
|
||||||
|
DIContainer.shared.register(\.logger, value: Logger(label: "dev.bscubed.Luminate"))
|
||||||
}
|
}
|
||||||
|
|
||||||
var scene: Scene {
|
var scene: Scene {
|
||||||
|
|
@ -74,6 +85,9 @@ struct Luminate: App {
|
||||||
}
|
}
|
||||||
.keyboardShortcut("r".ctrl()) { _ in
|
.keyboardShortcut("r".ctrl()) { _ in
|
||||||
}
|
}
|
||||||
|
#if DEBUG
|
||||||
|
.devel()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadSavedSession() {
|
private func loadSavedSession() {
|
||||||
|
|
@ -107,19 +121,27 @@ struct ContentView: View {
|
||||||
var client: JellyfinClient
|
var client: JellyfinClient
|
||||||
var userId: String
|
var userId: String
|
||||||
@State var stack: NavigationStack<Page> = .init()
|
@State var stack: NavigationStack<Page> = .init()
|
||||||
@Injected(\.pageAnimationTracker) var pageAnimationTracker
|
|
||||||
|
|
||||||
var view: Body {
|
var view: Body {
|
||||||
NavigationView($stack, "Luminate") { page in
|
NavigationView($stack, "Luminate") { page in
|
||||||
switch page {
|
switch page {
|
||||||
case .folder(let title, let items):
|
case .items(let title, let items, let type):
|
||||||
LibraryPage(title: title, items: items, navigation: $stack)
|
ItemPage(title: title, items: items, type: type, navigation: $stack)
|
||||||
.topToolbar {
|
.topToolbar {
|
||||||
ToolbarView()
|
ToolbarView()
|
||||||
}
|
}
|
||||||
.navigationTitle(title)
|
.navigationTitle(page.description)
|
||||||
case .library(let item):
|
case .item(let item, let type):
|
||||||
Text("REPLACE ME")
|
ItemPage(item: item, type: type, navigation: $stack)
|
||||||
|
.topToolbar {
|
||||||
|
ToolbarView()
|
||||||
|
}
|
||||||
|
.navigationTitle(page.description)
|
||||||
|
case .movieDetail(let item):
|
||||||
|
MovieDetailView(for: item)
|
||||||
|
.topToolbar {
|
||||||
|
ToolbarView()
|
||||||
|
}
|
||||||
|
.navigationTitle(page.description)
|
||||||
}
|
}
|
||||||
} initialView: {
|
} initialView: {
|
||||||
HomeView(navigation: $stack)
|
HomeView(navigation: $stack)
|
||||||
|
|
@ -128,11 +150,5 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
.navigationTitle("Luminate")
|
.navigationTitle("Luminate")
|
||||||
}
|
}
|
||||||
.pushed {
|
|
||||||
pageAnimationTracker.markPush()
|
|
||||||
}
|
|
||||||
.popped {
|
|
||||||
pageAnimationTracker.markPush()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,10 @@ public struct HomeView: View {
|
||||||
@Injected(\.client) var client
|
@Injected(\.client) var client
|
||||||
@Injected(\.userId) var userId
|
@Injected(\.userId) var userId
|
||||||
@Binding var navigation: NavigationStack<Page>
|
@Binding var navigation: NavigationStack<Page>
|
||||||
@State private var resumeItems: [Components.Schemas.BaseItemDto] = []
|
@State private var resumeItems: [BaseItemDto] = []
|
||||||
@State private var nextUpItems: [Components.Schemas.BaseItemDto] = []
|
@State private var nextUpItems: [BaseItemDto] = []
|
||||||
@State private var latestItems: [Components.Schemas.BaseItemDto] = []
|
@State private var latestItems: [BaseItemDto] = []
|
||||||
@State private var libraries: [Components.Schemas.BaseItemDto] = []
|
@State private var libraries: [BaseItemDto] = []
|
||||||
@State private var isLoading = true
|
@State private var isLoading = true
|
||||||
@State private var isLoadingData = false
|
@State private var isLoadingData = false
|
||||||
|
|
||||||
|
|
@ -55,6 +55,12 @@ public struct HomeView: View {
|
||||||
.frame(minWidth: 64)
|
.frame(minWidth: 64)
|
||||||
.frame(maxWidth: 64)
|
.frame(maxWidth: 64)
|
||||||
} else {
|
} else {
|
||||||
|
ItemGrid(
|
||||||
|
items: libraries,
|
||||||
|
type: .library,
|
||||||
|
navigation: $navigation
|
||||||
|
)
|
||||||
|
.padding(32, .bottom)
|
||||||
if !resumeItems.isEmpty {
|
if !resumeItems.isEmpty {
|
||||||
let title = "Continue Watching"
|
let title = "Continue Watching"
|
||||||
MediaRow(
|
MediaRow(
|
||||||
|
|
@ -62,7 +68,7 @@ public struct HomeView: View {
|
||||||
items: resumeItems,
|
items: resumeItems,
|
||||||
navigation: $navigation,
|
navigation: $navigation,
|
||||||
onSeeAll: {
|
onSeeAll: {
|
||||||
navigation.push(.folder(title: title, items: resumeItems))
|
navigation.push(.items(title: title, items: resumeItems))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.padding(32, .bottom)
|
.padding(32, .bottom)
|
||||||
|
|
@ -74,7 +80,7 @@ public struct HomeView: View {
|
||||||
items: nextUpItems,
|
items: nextUpItems,
|
||||||
navigation: $navigation,
|
navigation: $navigation,
|
||||||
onSeeAll: {
|
onSeeAll: {
|
||||||
navigation.push(.folder(title: title, items: nextUpItems))
|
navigation.push(.items(title: title, items: nextUpItems))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.padding(32, .bottom)
|
.padding(32, .bottom)
|
||||||
|
|
@ -86,20 +92,15 @@ public struct HomeView: View {
|
||||||
items: latestItems,
|
items: latestItems,
|
||||||
navigation: $navigation,
|
navigation: $navigation,
|
||||||
onSeeAll: {
|
onSeeAll: {
|
||||||
navigation.push(.folder(title: title, items: latestItems))
|
navigation.push(.items(title: title, items: latestItems))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.padding(32, .bottom)
|
.padding(32, .bottom)
|
||||||
}
|
}
|
||||||
LibraryGrid(
|
|
||||||
libraries: libraries,
|
|
||||||
navigation: $navigation
|
|
||||||
)
|
|
||||||
.padding(32, .bottom)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(8, .horizontal)
|
.padding(8, .horizontal)
|
||||||
.padding(32, .bottom)
|
.padding(32, .vertical)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.hscrollbarPolicy(.never)
|
.hscrollbarPolicy(.never)
|
||||||
|
|
@ -113,14 +114,17 @@ public struct HomeView: View {
|
||||||
Task {
|
Task {
|
||||||
async let resume = client.getItems(
|
async let resume = client.getItems(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
fields: [.primaryImageAspectRatio],
|
||||||
filters: [.isResumable],
|
filters: [.isResumable],
|
||||||
sortBy: [.datePlayed],
|
sortBy: [.datePlayed],
|
||||||
sortOrder: [.descending],
|
sortOrder: [.descending],
|
||||||
limit: 20,
|
limit: 20,
|
||||||
recursive: true
|
recursive: true
|
||||||
)
|
)
|
||||||
async let nextUp = client.getNextUp(userId: userId, limit: 20)
|
async let nextUp = client.getNextUp(
|
||||||
async let latest = client.getLatestMedia(userId: userId, limit: 20)
|
userId: userId, limit: 20, fields: [.primaryImageAspectRatio])
|
||||||
|
async let latest = client.getLatestMedia(
|
||||||
|
userId: userId, fields: [.primaryImageAspectRatio], limit: 20)
|
||||||
async let views = client.getUserViews(userId: userId)
|
async let views = client.getUserViews(userId: userId)
|
||||||
do {
|
do {
|
||||||
let (resume, nextUp, latest, views) = try await (resume, nextUp, latest, views)
|
let (resume, nextUp, latest, views) = try await (resume, nextUp, latest, views)
|
||||||
|
|
|
||||||
108
Sources/Luminate/Pages/ItemPage.swift
Normal file
108
Sources/Luminate/Pages/ItemPage.swift
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
//
|
||||||
|
// ItemPage.swift
|
||||||
|
// Luminate
|
||||||
|
//
|
||||||
|
// Created by Brendan Szymanski on 6/16/25.
|
||||||
|
//
|
||||||
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//
|
||||||
|
|
||||||
|
import Adwaita
|
||||||
|
import LuminateCore
|
||||||
|
import LuminateDI
|
||||||
|
import LuminateUI
|
||||||
|
|
||||||
|
public struct ItemPage: View {
|
||||||
|
|
||||||
|
private var title: String?
|
||||||
|
private var parentId: String?
|
||||||
|
private var type: DisplayType
|
||||||
|
|
||||||
|
@Binding var navigation: NavigationStack<Page>
|
||||||
|
|
||||||
|
@State private var items: [BaseItemDto] = []
|
||||||
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
@Injected(\.client) var client
|
||||||
|
@Injected(\.userId) var userId
|
||||||
|
|
||||||
|
nonisolated public init(
|
||||||
|
title: String, items: [BaseItemDto],
|
||||||
|
type: DisplayType = .mixed,
|
||||||
|
navigation: Binding<NavigationStack<Page>>
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self._items = .init(wrappedValue: items)
|
||||||
|
self.parentId = nil
|
||||||
|
self.type = type
|
||||||
|
_navigation = navigation
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated public init(
|
||||||
|
item: BaseItemDto,
|
||||||
|
type: DisplayType = .mixed,
|
||||||
|
navigation: Binding<NavigationStack<Page>>
|
||||||
|
) {
|
||||||
|
self.title = item.name
|
||||||
|
self.parentId = item.id
|
||||||
|
self.type = type
|
||||||
|
_isLoading = .init(wrappedValue: item.id != nil)
|
||||||
|
_navigation = navigation
|
||||||
|
}
|
||||||
|
|
||||||
|
public var view: Body {
|
||||||
|
ScrollView {
|
||||||
|
Clamp()
|
||||||
|
.maximumSize(1550)
|
||||||
|
.tighteningThreshold(550)
|
||||||
|
.child {
|
||||||
|
if isLoading, parentId != nil {
|
||||||
|
Spinner()
|
||||||
|
} else {
|
||||||
|
ItemGrid(
|
||||||
|
items: items,
|
||||||
|
type: type,
|
||||||
|
navigation: $navigation,
|
||||||
|
title: title
|
||||||
|
)
|
||||||
|
.padding(32, .vertical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.hscrollbarPolicy(.never)
|
||||||
|
.propagateNaturalHeight()
|
||||||
|
.onAppear {
|
||||||
|
loadIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadIfNeeded() {
|
||||||
|
guard let parentId else { return }
|
||||||
|
Task {
|
||||||
|
let result = try? await client.getItems(
|
||||||
|
userId: userId,
|
||||||
|
parentId: parentId,
|
||||||
|
fields: [.primaryImageAspectRatio],
|
||||||
|
sortBy: [.sortName],
|
||||||
|
sortOrder: [.ascending]
|
||||||
|
)
|
||||||
|
items = result?.items ?? []
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
//
|
|
||||||
// LibraryPage.swift
|
|
||||||
//
|
|
||||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
//
|
|
||||||
|
|
||||||
import Adwaita
|
|
||||||
import LuminateCore
|
|
||||||
import LuminateUI
|
|
||||||
|
|
||||||
public struct LibraryPage: View {
|
|
||||||
|
|
||||||
nonisolated public init(
|
|
||||||
title: String, items: [Components.Schemas.BaseItemDto],
|
|
||||||
navigation: Binding<NavigationStack<Page>>
|
|
||||||
) {
|
|
||||||
self.title = title
|
|
||||||
self.items = items
|
|
||||||
_navigation = navigation
|
|
||||||
}
|
|
||||||
|
|
||||||
private var items: [Components.Schemas.BaseItemDto]
|
|
||||||
private var title: String
|
|
||||||
@Binding var navigation: NavigationStack<Page>
|
|
||||||
|
|
||||||
public var view: Body {
|
|
||||||
ScrollView {
|
|
||||||
Clamp()
|
|
||||||
.maximumSize(1550)
|
|
||||||
.tighteningThreshold(550)
|
|
||||||
.child {
|
|
||||||
LibraryGrid(
|
|
||||||
libraries: items,
|
|
||||||
navigation: $navigation,
|
|
||||||
title: title
|
|
||||||
)
|
|
||||||
.padding(32, .bottom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.hscrollbarPolicy(.never)
|
|
||||||
.propagateNaturalHeight()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -21,11 +21,11 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension Components.Schemas.BaseItemPerson: Identifiable {}
|
extension BaseItemPerson: Identifiable {}
|
||||||
|
|
||||||
extension Components.Schemas.BaseItemDto: Identifiable {}
|
extension BaseItemDto: Identifiable {}
|
||||||
|
|
||||||
extension Components.Schemas.SearchHint {
|
extension SearchHint {
|
||||||
public var runtimeString: String {
|
public var runtimeString: String {
|
||||||
guard let ticks = runTimeTicks else { return "" }
|
guard let ticks = runTimeTicks else { return "" }
|
||||||
let totalSeconds = Int(ticks / 10_000_000)
|
let totalSeconds = Int(ticks / 10_000_000)
|
||||||
|
|
@ -36,7 +36,7 @@ extension Components.Schemas.SearchHint {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Components.Schemas.BaseItemDto {
|
extension BaseItemDto {
|
||||||
public var runtimeString: String {
|
public var runtimeString: String {
|
||||||
guard let ticks = runTimeTicks else { return "" }
|
guard let ticks = runTimeTicks else { return "" }
|
||||||
let totalSeconds = Int(ticks / 10_000_000)
|
let totalSeconds = Int(ticks / 10_000_000)
|
||||||
|
|
@ -46,16 +46,61 @@ extension Components.Schemas.BaseItemDto {
|
||||||
return "\(minutes)m"
|
return "\(minutes)m"
|
||||||
}
|
}
|
||||||
|
|
||||||
public var yearString: String {
|
public var yearString: String? {
|
||||||
guard let year = productionYear else { return "" }
|
productionYear.map(String.init)
|
||||||
return "\(year)"
|
}
|
||||||
|
|
||||||
|
public var episodePlacementString: String? {
|
||||||
|
guard let seasonNumber = parentIndexNumber else { return nil }
|
||||||
|
guard let episodeNumber = indexNumber else { return nil }
|
||||||
|
guard let episodeName = name else { return nil }
|
||||||
|
return "S\(seasonNumber):E\(episodeNumber) - \(episodeName)"
|
||||||
}
|
}
|
||||||
|
|
||||||
public var primaryImageTag: String? {
|
public var primaryImageTag: String? {
|
||||||
imageTags?.additionalProperties["Primary"]
|
imageTags?.additionalProperties["Primary"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var seriesRunYears: String? {
|
||||||
|
guard isShow else { return nil }
|
||||||
|
guard let startYear = yearString else { return nil }
|
||||||
|
guard status == "Ended" else { return "\(startYear) - Present" }
|
||||||
|
guard let endDate else { return nil }
|
||||||
|
let endYear = String(Calendar.current.component(.year, from: endDate))
|
||||||
|
return startYear == endYear ? startYear : "\(startYear) - \(endYear)"
|
||||||
|
}
|
||||||
|
|
||||||
public var backdropImageTag: String? {
|
public var backdropImageTag: String? {
|
||||||
backdropImageTags?.first
|
backdropImageTags?.first
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var type: BaseItemKind? {
|
||||||
|
_type?.value1
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isShow: Bool {
|
||||||
|
_type?.value1 == .series
|
||||||
|
}
|
||||||
|
|
||||||
|
public var displayType: DisplayType {
|
||||||
|
return switch type {
|
||||||
|
case .season: .season
|
||||||
|
case .episode: .episode
|
||||||
|
case .movie: .movie
|
||||||
|
case .person: .person
|
||||||
|
case .series: .series
|
||||||
|
default: .mixed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var childDisplayType: DisplayType? {
|
||||||
|
return switch type {
|
||||||
|
case .season: .episode
|
||||||
|
case .episode: .none
|
||||||
|
case .movie: .none
|
||||||
|
case .person: .mixed
|
||||||
|
case .series: .season
|
||||||
|
default: .mixed
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
75
Sources/LuminateCore/DisplayType.swift
Normal file
75
Sources/LuminateCore/DisplayType.swift
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
//
|
||||||
|
// MediaRow.swift
|
||||||
|
//
|
||||||
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum DisplayType {
|
||||||
|
case mixed
|
||||||
|
case season
|
||||||
|
case episode
|
||||||
|
case movie
|
||||||
|
case person
|
||||||
|
case library
|
||||||
|
case series
|
||||||
|
|
||||||
|
public var aspectRatio: Float {
|
||||||
|
return switch self {
|
||||||
|
case .mixed: 1.5
|
||||||
|
case .season: 1.5
|
||||||
|
case .episode: 0.5625
|
||||||
|
case .movie: 1.5
|
||||||
|
case .person: 1
|
||||||
|
case .library: 0.5625
|
||||||
|
case .series: 1.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func itemId(for item: BaseItemDto) -> String? {
|
||||||
|
return switch self {
|
||||||
|
case .mixed: item.seriesId ?? item.id
|
||||||
|
default: item.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func title(for item: BaseItemDto) -> String? {
|
||||||
|
return switch self {
|
||||||
|
case .mixed: item.seriesName ?? item.name
|
||||||
|
case .library: .none
|
||||||
|
default: item.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func subtitle(for item: BaseItemDto) -> String? {
|
||||||
|
return switch self {
|
||||||
|
case .library: .none
|
||||||
|
case .mixed: item.episodePlacementString ?? item.seriesRunYears ?? item.yearString
|
||||||
|
default: item.yearString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func itemWidth(isMobile: Bool = false) -> Int {
|
||||||
|
switch self {
|
||||||
|
case .episode: return isMobile ? 150 : 300
|
||||||
|
case .library: return isMobile ? 125 : 250
|
||||||
|
default: return isMobile ? 100 : 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
|
||||||
#if canImport(FoundationNetworking)
|
#if canImport(FoundationNetworking)
|
||||||
import FoundationNetworking
|
import FoundationNetworking
|
||||||
|
|
@ -28,36 +29,65 @@ import Foundation
|
||||||
public actor ImageService {
|
public actor ImageService {
|
||||||
private let cacheDir: URL
|
private let cacheDir: URL
|
||||||
private let memoryCache = NSCache<NSString, NSData>()
|
private let memoryCache = NSCache<NSString, NSData>()
|
||||||
|
private var activeDownloads = 0
|
||||||
|
private var imagesDownloaded = 0
|
||||||
|
private let maxConcurrent: Int
|
||||||
|
private var pendingContinuations: [CheckedContinuation<Void, Never>] = []
|
||||||
|
|
||||||
public init(cacheDir: URL? = nil) {
|
private var logger: Logger = .init(label: "dev.bscubed.Luminate")
|
||||||
|
|
||||||
|
public init(cacheDir: URL? = nil, maxConcurrent: Int = 6) {
|
||||||
let defaultCache = FileManager.default.urls(
|
let defaultCache = FileManager.default.urls(
|
||||||
for: .cachesDirectory, in: .userDomainMask
|
for: .cachesDirectory, in: .userDomainMask
|
||||||
).first!.appendingPathComponent("luminate/images")
|
).first!.appendingPathComponent("luminate/images")
|
||||||
self.cacheDir = cacheDir ?? defaultCache
|
self.cacheDir = cacheDir ?? defaultCache
|
||||||
|
self.maxConcurrent = maxConcurrent
|
||||||
try? FileManager.default.createDirectory(
|
try? FileManager.default.createDirectory(
|
||||||
at: self.cacheDir, withIntermediateDirectories: true)
|
at: self.cacheDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
logger.debug("New ImageService created")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func loadImage(url: URL) async throws -> Data {
|
public func loadImage(url: URL) async throws -> Data {
|
||||||
let key = url.absoluteString as NSString
|
let key = url.absoluteString as NSString
|
||||||
if let cached = memoryCache.object(forKey: key) {
|
if let cached = memoryCache.object(forKey: key) {
|
||||||
|
logger.debug("Hit memory cache for \(key)")
|
||||||
return cached as Data
|
return cached as Data
|
||||||
}
|
}
|
||||||
let diskKey = url.absoluteString.data(using: .utf8)!.base64EncodedString()
|
let diskURL = diskCacheURL(for: url)
|
||||||
.replacingOccurrences(of: "/", with: "_")
|
|
||||||
let diskURL = cacheDir.appendingPathComponent(diskKey)
|
|
||||||
if let data = try? Data(contentsOf: diskURL) {
|
if let data = try? Data(contentsOf: diskURL) {
|
||||||
memoryCache.setObject(data as NSData, forKey: key)
|
memoryCache.setObject(data as NSData, forKey: key)
|
||||||
|
logger.debug("Hit disk cache for \(key)")
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if activeDownloads >= maxConcurrent {
|
||||||
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||||
|
logger.debug("Idk what this is doing tbh")
|
||||||
|
pendingContinuations.append(continuation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeDownloads += 1
|
||||||
|
defer {
|
||||||
|
activeDownloads -= 1
|
||||||
|
if !pendingContinuations.isEmpty {
|
||||||
|
let next = pendingContinuations.removeFirst()
|
||||||
|
next.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("Started downloading \(url)")
|
||||||
let (data, _) = try await URLSession.shared.data(from: url)
|
let (data, _) = try await URLSession.shared.data(from: url)
|
||||||
let nsData = data as NSData
|
let nsData = data as NSData
|
||||||
memoryCache.setObject(nsData, forKey: key)
|
memoryCache.setObject(nsData, forKey: key)
|
||||||
try? data.write(to: diskURL)
|
try? data.write(to: diskURL)
|
||||||
|
logger.debug("Finished downloading \(url)")
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
public func prefetch(urls: [URL]) async {
|
public func prefetch(urls: [URL]) async {
|
||||||
|
logger.debug("Prefetching \(urls)")
|
||||||
await withTaskGroup(of: Void.self) { group in
|
await withTaskGroup(of: Void.self) { group in
|
||||||
for url in urls {
|
for url in urls {
|
||||||
group.addTask { _ = try? await self.loadImage(url: url) }
|
group.addTask { _ = try? await self.loadImage(url: url) }
|
||||||
|
|
@ -66,8 +96,15 @@ public actor ImageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func clearCache() {
|
public func clearCache() {
|
||||||
|
logger.debug("Clearing all cache...")
|
||||||
memoryCache.removeAllObjects()
|
memoryCache.removeAllObjects()
|
||||||
try? FileManager.default.removeItem(at: cacheDir)
|
try? FileManager.default.removeItem(at: cacheDir)
|
||||||
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func diskCacheURL(for url: URL) -> URL {
|
||||||
|
let diskKey = url.absoluteString.data(using: .utf8)!.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
return cacheDir.appendingPathComponent(diskKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ public actor JellyfinClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func authenticate(username: String, password: String) async throws
|
public func authenticate(username: String, password: String) async throws
|
||||||
-> Components.Schemas.AuthenticationResult
|
-> AuthenticationResult
|
||||||
{
|
{
|
||||||
let response = try await client.authenticateUserByName(
|
let response = try await client.authenticateUserByName(
|
||||||
Operations.AuthenticateUserByName.Input(
|
Operations.AuthenticateUserByName.Input(
|
||||||
|
|
@ -187,17 +187,17 @@ public actor JellyfinClient {
|
||||||
public func getItems(
|
public func getItems(
|
||||||
userId: String,
|
userId: String,
|
||||||
parentId: String? = nil,
|
parentId: String? = nil,
|
||||||
includeItemTypes: [Components.Schemas.BaseItemKind]? = nil,
|
includeItemTypes: [BaseItemKind]? = nil,
|
||||||
fields: [Components.Schemas.ItemFields]? = nil,
|
fields: [ItemFields]? = nil,
|
||||||
filters: [Components.Schemas.ItemFilter]? = nil,
|
filters: [ItemFilter]? = nil,
|
||||||
sortBy: [Components.Schemas.ItemSortBy]? = nil,
|
sortBy: [ItemSortBy]? = nil,
|
||||||
sortOrder: [Components.Schemas.SortOrder]? = nil,
|
sortOrder: [SortOrder]? = nil,
|
||||||
searchTerm: String? = nil,
|
searchTerm: String? = nil,
|
||||||
startIndex: Int32? = nil,
|
startIndex: Int32? = nil,
|
||||||
limit: Int32? = nil,
|
limit: Int32? = nil,
|
||||||
recursive: Bool? = nil,
|
recursive: Bool? = nil,
|
||||||
isFavorite: Bool? = nil
|
isFavorite: Bool? = nil
|
||||||
) async throws -> Components.Schemas.BaseItemDtoQueryResult {
|
) async throws -> BaseItemDtoQueryResult {
|
||||||
guard token != nil else { throw JellyfinError.notAuthenticated }
|
guard token != nil else { throw JellyfinError.notAuthenticated }
|
||||||
var query = Operations.GetItems.Input.Query(userId: userId)
|
var query = Operations.GetItems.Input.Query(userId: userId)
|
||||||
query.parentId = parentId
|
query.parentId = parentId
|
||||||
|
|
@ -234,7 +234,7 @@ public actor JellyfinClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getItem(itemId: String, userId: String? = nil) async throws
|
public func getItem(itemId: String, userId: String? = nil) async throws
|
||||||
-> Components.Schemas.BaseItemDto
|
-> BaseItemDto
|
||||||
{
|
{
|
||||||
guard token != nil else { throw JellyfinError.notAuthenticated }
|
guard token != nil else { throw JellyfinError.notAuthenticated }
|
||||||
let response = try await client.getItem(
|
let response = try await client.getItem(
|
||||||
|
|
@ -264,7 +264,7 @@ public actor JellyfinClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getUserViews(userId: String) async throws
|
public func getUserViews(userId: String) async throws
|
||||||
-> Components.Schemas.BaseItemDtoQueryResult
|
-> BaseItemDtoQueryResult
|
||||||
{
|
{
|
||||||
guard token != nil else { throw JellyfinError.notAuthenticated }
|
guard token != nil else { throw JellyfinError.notAuthenticated }
|
||||||
let response = try await client.getUserViews(
|
let response = try await client.getUserViews(
|
||||||
|
|
@ -296,12 +296,12 @@ public actor JellyfinClient {
|
||||||
userId: String,
|
userId: String,
|
||||||
startIndex: Int32? = nil,
|
startIndex: Int32? = nil,
|
||||||
limit: Int32? = nil,
|
limit: Int32? = nil,
|
||||||
fields: [Components.Schemas.ItemFields]? = nil,
|
fields: [ItemFields]? = nil,
|
||||||
seriesId: String? = nil,
|
seriesId: String? = nil,
|
||||||
parentId: String? = nil,
|
parentId: String? = nil,
|
||||||
enableResumable: Bool? = nil,
|
enableResumable: Bool? = nil,
|
||||||
enableRewatching: Bool? = nil
|
enableRewatching: Bool? = nil
|
||||||
) async throws -> Components.Schemas.BaseItemDtoQueryResult {
|
) async throws -> BaseItemDtoQueryResult {
|
||||||
guard token != nil else { throw JellyfinError.notAuthenticated }
|
guard token != nil else { throw JellyfinError.notAuthenticated }
|
||||||
var query = Operations.GetNextUp.Input.Query(userId: userId)
|
var query = Operations.GetNextUp.Input.Query(userId: userId)
|
||||||
query.startIndex = startIndex
|
query.startIndex = startIndex
|
||||||
|
|
@ -336,13 +336,13 @@ public actor JellyfinClient {
|
||||||
public func getSeasons(
|
public func getSeasons(
|
||||||
seriesId: String,
|
seriesId: String,
|
||||||
userId: String,
|
userId: String,
|
||||||
fields: [Components.Schemas.ItemFields]? = nil,
|
fields: [ItemFields]? = nil,
|
||||||
isSpecialSeason: Bool? = nil,
|
isSpecialSeason: Bool? = nil,
|
||||||
enableImages: Bool? = nil,
|
enableImages: Bool? = nil,
|
||||||
imageTypeLimit: Int32? = nil,
|
imageTypeLimit: Int32? = nil,
|
||||||
enableImageTypes: [Components.Schemas.ImageType]? = nil,
|
enableImageTypes: [ImageType]? = nil,
|
||||||
enableUserData: Bool? = nil
|
enableUserData: Bool? = nil
|
||||||
) async throws -> Components.Schemas.BaseItemDtoQueryResult {
|
) async throws -> BaseItemDtoQueryResult {
|
||||||
guard token != nil else { throw JellyfinError.notAuthenticated }
|
guard token != nil else { throw JellyfinError.notAuthenticated }
|
||||||
var query = Operations.GetSeasons.Input.Query(userId: userId)
|
var query = Operations.GetSeasons.Input.Query(userId: userId)
|
||||||
query.fields = fields
|
query.fields = fields
|
||||||
|
|
@ -384,14 +384,14 @@ public actor JellyfinClient {
|
||||||
userId: String,
|
userId: String,
|
||||||
seasonId: String? = nil,
|
seasonId: String? = nil,
|
||||||
season: Int32? = nil,
|
season: Int32? = nil,
|
||||||
fields: [Components.Schemas.ItemFields]? = nil,
|
fields: [ItemFields]? = nil,
|
||||||
startIndex: Int32? = nil,
|
startIndex: Int32? = nil,
|
||||||
limit: Int32? = nil,
|
limit: Int32? = nil,
|
||||||
enableImages: Bool? = nil,
|
enableImages: Bool? = nil,
|
||||||
imageTypeLimit: Int32? = nil,
|
imageTypeLimit: Int32? = nil,
|
||||||
enableImageTypes: [Components.Schemas.ImageType]? = nil,
|
enableImageTypes: [ImageType]? = nil,
|
||||||
enableUserData: Bool? = nil
|
enableUserData: Bool? = nil
|
||||||
) async throws -> Components.Schemas.BaseItemDtoQueryResult {
|
) async throws -> BaseItemDtoQueryResult {
|
||||||
guard token != nil else { throw JellyfinError.notAuthenticated }
|
guard token != nil else { throw JellyfinError.notAuthenticated }
|
||||||
var query = Operations.GetEpisodes.Input.Query(userId: userId)
|
var query = Operations.GetEpisodes.Input.Query(userId: userId)
|
||||||
query.seasonId = seasonId
|
query.seasonId = seasonId
|
||||||
|
|
@ -436,9 +436,9 @@ public actor JellyfinClient {
|
||||||
userId: String? = nil,
|
userId: String? = nil,
|
||||||
startIndex: Int32? = nil,
|
startIndex: Int32? = nil,
|
||||||
limit: Int32? = nil,
|
limit: Int32? = nil,
|
||||||
includeItemTypes: [Components.Schemas.BaseItemKind]? = nil,
|
includeItemTypes: [BaseItemKind]? = nil,
|
||||||
parentId: String? = nil
|
parentId: String? = nil
|
||||||
) async throws -> Components.Schemas.SearchHintResult {
|
) async throws -> SearchHintResult {
|
||||||
guard token != nil else { throw JellyfinError.notAuthenticated }
|
guard token != nil else { throw JellyfinError.notAuthenticated }
|
||||||
var query = Operations.GetSearchHints.Input.Query(searchTerm: searchTerm)
|
var query = Operations.GetSearchHints.Input.Query(searchTerm: searchTerm)
|
||||||
query.userId = userId
|
query.userId = userId
|
||||||
|
|
@ -470,7 +470,7 @@ public actor JellyfinClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func markPlayedItem(itemId: String, userId: String, datePlayed: Date? = nil) async throws
|
public func markPlayedItem(itemId: String, userId: String, datePlayed: Date? = nil) async throws
|
||||||
-> Components.Schemas.UserItemDataDto
|
-> UserItemDataDto
|
||||||
{
|
{
|
||||||
guard token != nil else { throw JellyfinError.notAuthenticated }
|
guard token != nil else { throw JellyfinError.notAuthenticated }
|
||||||
let response = try await client.markPlayedItem(
|
let response = try await client.markPlayedItem(
|
||||||
|
|
@ -502,7 +502,7 @@ public actor JellyfinClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func markUnplayedItem(itemId: String, userId: String) async throws
|
public func markUnplayedItem(itemId: String, userId: String) async throws
|
||||||
-> Components.Schemas.UserItemDataDto
|
-> UserItemDataDto
|
||||||
{
|
{
|
||||||
guard token != nil else { throw JellyfinError.notAuthenticated }
|
guard token != nil else { throw JellyfinError.notAuthenticated }
|
||||||
let response = try await client.markUnplayedItem(
|
let response = try await client.markUnplayedItem(
|
||||||
|
|
@ -534,7 +534,7 @@ public actor JellyfinClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func markFavoriteItem(itemId: String, userId: String) async throws
|
public func markFavoriteItem(itemId: String, userId: String) async throws
|
||||||
-> Components.Schemas.UserItemDataDto
|
-> UserItemDataDto
|
||||||
{
|
{
|
||||||
guard token != nil else { throw JellyfinError.notAuthenticated }
|
guard token != nil else { throw JellyfinError.notAuthenticated }
|
||||||
let response = try await client.markFavoriteItem(
|
let response = try await client.markFavoriteItem(
|
||||||
|
|
@ -564,7 +564,7 @@ public actor JellyfinClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func unmarkFavoriteItem(itemId: String, userId: String) async throws
|
public func unmarkFavoriteItem(itemId: String, userId: String) async throws
|
||||||
-> Components.Schemas.UserItemDataDto
|
-> UserItemDataDto
|
||||||
{
|
{
|
||||||
guard token != nil else { throw JellyfinError.notAuthenticated }
|
guard token != nil else { throw JellyfinError.notAuthenticated }
|
||||||
let response = try await client.unmarkFavoriteItem(
|
let response = try await client.unmarkFavoriteItem(
|
||||||
|
|
@ -594,7 +594,7 @@ public actor JellyfinClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getPlaybackInfo(itemId: String, userId: String) async throws
|
public func getPlaybackInfo(itemId: String, userId: String) async throws
|
||||||
-> Components.Schemas.PlaybackInfoResponse
|
-> PlaybackInfoResponse
|
||||||
{
|
{
|
||||||
guard token != nil else { throw JellyfinError.notAuthenticated }
|
guard token != nil else { throw JellyfinError.notAuthenticated }
|
||||||
let response = try await client.getPlaybackInfo(
|
let response = try await client.getPlaybackInfo(
|
||||||
|
|
@ -625,7 +625,7 @@ public actor JellyfinClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reportPlaybackStart(info: Components.Schemas.PlaybackStartInfo) async throws {
|
public func reportPlaybackStart(info: PlaybackStartInfo) async throws {
|
||||||
guard token != nil else { throw JellyfinError.notAuthenticated }
|
guard token != nil else { throw JellyfinError.notAuthenticated }
|
||||||
let response = try await client.reportPlaybackStart(
|
let response = try await client.reportPlaybackStart(
|
||||||
Operations.ReportPlaybackStart.Input(
|
Operations.ReportPlaybackStart.Input(
|
||||||
|
|
@ -645,7 +645,7 @@ public actor JellyfinClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reportPlaybackProgress(info: Components.Schemas.PlaybackProgressInfo) async throws {
|
public func reportPlaybackProgress(info: PlaybackProgressInfo) async throws {
|
||||||
guard token != nil else { throw JellyfinError.notAuthenticated }
|
guard token != nil else { throw JellyfinError.notAuthenticated }
|
||||||
let response = try await client.reportPlaybackProgress(
|
let response = try await client.reportPlaybackProgress(
|
||||||
Operations.ReportPlaybackProgress.Input(
|
Operations.ReportPlaybackProgress.Input(
|
||||||
|
|
@ -665,7 +665,7 @@ public actor JellyfinClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reportPlaybackStopped(info: Components.Schemas.PlaybackStopInfo) async throws {
|
public func reportPlaybackStopped(info: PlaybackStopInfo) async throws {
|
||||||
guard token != nil else { throw JellyfinError.notAuthenticated }
|
guard token != nil else { throw JellyfinError.notAuthenticated }
|
||||||
let response = try await client.reportPlaybackStopped(
|
let response = try await client.reportPlaybackStopped(
|
||||||
Operations.ReportPlaybackStopped.Input(
|
Operations.ReportPlaybackStopped.Input(
|
||||||
|
|
@ -688,15 +688,15 @@ public actor JellyfinClient {
|
||||||
public func getLatestMedia(
|
public func getLatestMedia(
|
||||||
userId: String,
|
userId: String,
|
||||||
parentId: String? = nil,
|
parentId: String? = nil,
|
||||||
fields: [Components.Schemas.ItemFields]? = nil,
|
fields: [ItemFields]? = nil,
|
||||||
includeItemTypes: [Components.Schemas.BaseItemKind]? = nil,
|
includeItemTypes: [BaseItemKind]? = nil,
|
||||||
limit: Int32? = nil,
|
limit: Int32? = nil,
|
||||||
enableImages: Bool? = nil,
|
enableImages: Bool? = nil,
|
||||||
imageTypeLimit: Int32? = nil,
|
imageTypeLimit: Int32? = nil,
|
||||||
enableImageTypes: [Components.Schemas.ImageType]? = nil,
|
enableImageTypes: [ImageType]? = nil,
|
||||||
enableUserData: Bool? = nil,
|
enableUserData: Bool? = nil,
|
||||||
groupItems: Bool? = nil
|
groupItems: Bool? = nil
|
||||||
) async throws -> [Components.Schemas.BaseItemDto] {
|
) async throws -> [BaseItemDto] {
|
||||||
guard token != nil else { throw JellyfinError.notAuthenticated }
|
guard token != nil else { throw JellyfinError.notAuthenticated }
|
||||||
var query = Operations.GetLatestMedia.Input.Query(userId: userId)
|
var query = Operations.GetLatestMedia.Input.Query(userId: userId)
|
||||||
query.parentId = parentId
|
query.parentId = parentId
|
||||||
|
|
@ -731,8 +731,8 @@ public actor JellyfinClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func imageURL(
|
nonisolated public func imageURL(
|
||||||
itemId: String, imageType: Components.Schemas.ImageType, tag: String? = nil,
|
itemId: String, imageType: ImageType, tag: String? = nil,
|
||||||
maxWidth: Int32? = nil, quality: Int32? = 90
|
maxWidth: Int32? = nil, quality: Int32? = 90
|
||||||
) -> URL? {
|
) -> URL? {
|
||||||
guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else {
|
guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else {
|
||||||
|
|
@ -747,7 +747,7 @@ public actor JellyfinClient {
|
||||||
return components.url
|
return components.url
|
||||||
}
|
}
|
||||||
|
|
||||||
public func userImageURL(userId: String, tag: String? = nil) -> URL? {
|
nonisolated public func userImageURL(userId: String, tag: String? = nil) -> URL? {
|
||||||
guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else {
|
guard var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,24 @@
|
||||||
@_exported import OpenAPIRuntime
|
@_exported import OpenAPIRuntime
|
||||||
@_exported import OpenAPIURLSession
|
@_exported import OpenAPIURLSession
|
||||||
|
|
||||||
|
public typealias AuthenticationResult = Components.Schemas.AuthenticationResult
|
||||||
|
public typealias BaseItemDto = Components.Schemas.BaseItemDto
|
||||||
|
public typealias BaseItemDtoQueryResult = Components.Schemas.BaseItemDtoQueryResult
|
||||||
|
public typealias BaseItemKind = Components.Schemas.BaseItemKind
|
||||||
|
public typealias BaseItemPerson = Components.Schemas.BaseItemPerson
|
||||||
|
public typealias ImageType = Components.Schemas.ImageType
|
||||||
|
public typealias ItemFields = Components.Schemas.ItemFields
|
||||||
|
public typealias ItemFilter = Components.Schemas.ItemFilter
|
||||||
|
public typealias ItemSortBy = Components.Schemas.ItemSortBy
|
||||||
|
public typealias PlaybackInfoResponse = Components.Schemas.PlaybackInfoResponse
|
||||||
|
public typealias PlaybackProgressInfo = Components.Schemas.PlaybackProgressInfo
|
||||||
|
public typealias PlaybackStartInfo = Components.Schemas.PlaybackStartInfo
|
||||||
|
public typealias PlaybackStopInfo = Components.Schemas.PlaybackStopInfo
|
||||||
|
public typealias SearchHint = Components.Schemas.SearchHint
|
||||||
|
public typealias SearchHintResult = Components.Schemas.SearchHintResult
|
||||||
|
public typealias SortOrder = Components.Schemas.SortOrder
|
||||||
|
public typealias UserItemDataDto = Components.Schemas.UserItemDataDto
|
||||||
|
|
||||||
public enum LuminateCore {
|
public enum LuminateCore {
|
||||||
public static let version = "0.1.0"
|
public static let version = "0.1.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,15 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
public enum Page: CustomStringConvertible {
|
public enum Page: CustomStringConvertible {
|
||||||
case library(item: Components.Schemas.BaseItemDto)
|
case item(item: BaseItemDto, type: DisplayType = .mixed)
|
||||||
case folder(title: String, items: [Components.Schemas.BaseItemDto])
|
case items(title: String, items: [BaseItemDto], type: DisplayType = .mixed)
|
||||||
|
case movieDetail(item: BaseItemDto)
|
||||||
|
|
||||||
public var description: String {
|
public var description: String {
|
||||||
switch self {
|
return switch self {
|
||||||
case .library(let item):
|
case .item(let item, _): item.name ?? "Library"
|
||||||
return item.name ?? "Library"
|
case .items(let title, _, _): title
|
||||||
case .folder(let title, _):
|
case .movieDetail(let item): item.name ?? "Luminate"
|
||||||
return title
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,10 @@ public actor SQLiteStore: PersistenceService {
|
||||||
db = try Connection(dbURL.path)
|
db = try Connection(dbURL.path)
|
||||||
db.busyTimeout = 5
|
db.busyTimeout = 5
|
||||||
try db.execute("PRAGMA journal_mode = WAL")
|
try db.execute("PRAGMA journal_mode = WAL")
|
||||||
try migrate()
|
try Self.migrate(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func migrate() throws {
|
private static func migrate(_ db: Connection) throws {
|
||||||
let version = db.userVersion
|
let version = db.userVersion
|
||||||
switch version {
|
switch version {
|
||||||
case 0:
|
case 0:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// PageAnimationTracking.swift
|
// ViewUpdateScheduling.swift
|
||||||
//
|
//
|
||||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||||
//
|
//
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public protocol PageAnimationTracking: AnyObject {
|
public protocol ViewUpdateScheduling: AnyObject {
|
||||||
var isAnimating: Bool { get }
|
func scheduleFlush()
|
||||||
func markPush()
|
|
||||||
}
|
}
|
||||||
|
|
@ -21,13 +21,21 @@
|
||||||
|
|
||||||
import Adwaita
|
import Adwaita
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Synchronization
|
||||||
|
|
||||||
public final class DIContainer: @unchecked Sendable {
|
public final class DIContainer: @unchecked Sendable {
|
||||||
|
|
||||||
public static let shared = DIContainer()
|
public static let shared = DIContainer()
|
||||||
public private(set) var values = InjectionValues()
|
|
||||||
private var observers: [AnyKeyPath: [UUID: @Sendable () -> Void]] = [:]
|
private struct State {
|
||||||
private let lock = NSLock()
|
var values = InjectionValues()
|
||||||
|
var observers: [AnyKeyPath: [UUID: @Sendable () -> Void]] = [:]
|
||||||
|
}
|
||||||
|
private let state = Mutex<State>(.init())
|
||||||
|
|
||||||
|
public var values: InjectionValues {
|
||||||
|
state.withLock { $0.values }
|
||||||
|
}
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
|
|
@ -35,8 +43,8 @@ public final class DIContainer: @unchecked Sendable {
|
||||||
_ keyPath: WritableKeyPath<InjectionValues, T?>,
|
_ keyPath: WritableKeyPath<InjectionValues, T?>,
|
||||||
value: T
|
value: T
|
||||||
) {
|
) {
|
||||||
lock.withLock {
|
state.withLock {
|
||||||
values[keyPath: keyPath] = value
|
$0.values[keyPath: keyPath] = value
|
||||||
}
|
}
|
||||||
notifyObservers(for: keyPath)
|
notifyObservers(for: keyPath)
|
||||||
}
|
}
|
||||||
|
|
@ -44,8 +52,8 @@ public final class DIContainer: @unchecked Sendable {
|
||||||
public func resolve<T>(
|
public func resolve<T>(
|
||||||
_ keyPath: KeyPath<InjectionValues, T?>
|
_ keyPath: KeyPath<InjectionValues, T?>
|
||||||
) -> T {
|
) -> T {
|
||||||
lock.withLock {
|
state.withLock {
|
||||||
guard let value = values[keyPath: keyPath] else {
|
guard let value = $0.values[keyPath: keyPath] else {
|
||||||
fatalError(
|
fatalError(
|
||||||
"DIContainer: No value registered for \(keyPath). "
|
"DIContainer: No value registered for \(keyPath). "
|
||||||
+ "Call DIContainer.shared.register(\\.key, value:) during app startup."
|
+ "Call DIContainer.shared.register(\\.key, value:) during app startup."
|
||||||
|
|
@ -61,31 +69,30 @@ public final class DIContainer: @unchecked Sendable {
|
||||||
handler: @escaping @Sendable () -> Void
|
handler: @escaping @Sendable () -> Void
|
||||||
) -> UUID {
|
) -> UUID {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
lock.withLock {
|
state.withLock {
|
||||||
observers[keyPath, default: [:]][id] = handler
|
$0.observers[keyPath, default: [:]][id] = handler
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeObserver(_ id: UUID) {
|
func removeObserver(_ id: UUID) {
|
||||||
lock.withLock {
|
state.withLock {
|
||||||
for keyPath in observers.keys {
|
for keyPath in $0.observers.keys {
|
||||||
observers[keyPath]?.removeValue(forKey: id)
|
$0.observers[keyPath]?.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func notifyObservers(for keyPath: AnyKeyPath) {
|
private func notifyObservers(for keyPath: AnyKeyPath) {
|
||||||
let handlers: [@Sendable () -> Void] = lock.withLock {
|
let handlers: [@Sendable () -> Void] = state.withLock {
|
||||||
Array((observers[keyPath] ?? [:]).values)
|
Array(($0.observers[keyPath] ?? [:]).values)
|
||||||
}
|
}
|
||||||
handlers.forEach { $0() }
|
handlers.forEach { $0() }
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reset() {
|
public func reset() {
|
||||||
lock.withLock {
|
state.withLock {
|
||||||
values = InjectionValues()
|
$0 = State()
|
||||||
observers.removeAll()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Logging
|
||||||
import LuminateCore
|
import LuminateCore
|
||||||
|
|
||||||
public struct InjectionValues {
|
public struct InjectionValues {
|
||||||
|
|
@ -29,7 +30,8 @@ public struct InjectionValues {
|
||||||
public var imageService: ImageService?
|
public var imageService: ImageService?
|
||||||
public var webSocketClient: WebSocketClient?
|
public var webSocketClient: WebSocketClient?
|
||||||
public var persistence: PersistenceService?
|
public var persistence: PersistenceService?
|
||||||
public var pageAnimationTracker: (any PageAnimationTracking)?
|
public var viewUpdateScheduler: (any ViewUpdateScheduling)?
|
||||||
|
public var logger: Logger?
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import LuminateCore
|
||||||
|
|
||||||
public struct PlayerView: View {
|
public struct PlayerView: View {
|
||||||
|
|
||||||
public var item: Components.Schemas.BaseItemDto
|
public var item: BaseItemDto
|
||||||
public var client: JellyfinClient
|
public var client: JellyfinClient
|
||||||
public var userId: String
|
public var userId: String
|
||||||
public var mediaSourceId: String
|
public var mediaSourceId: String
|
||||||
|
|
@ -38,7 +38,7 @@ public struct PlayerView: View {
|
||||||
public var onClose: () -> Void
|
public var onClose: () -> Void
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
item: Components.Schemas.BaseItemDto,
|
item: BaseItemDto,
|
||||||
client: JellyfinClient,
|
client: JellyfinClient,
|
||||||
userId: String,
|
userId: String,
|
||||||
mediaSourceId: String,
|
mediaSourceId: String,
|
||||||
|
|
|
||||||
139
Sources/LuminateUI/Components/AspectContainer.swift
Normal file
139
Sources/LuminateUI/Components/AspectContainer.swift
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
//
|
||||||
|
// AspectContainer.swift
|
||||||
|
//
|
||||||
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//
|
||||||
|
|
||||||
|
import Adwaita
|
||||||
|
import CGtkWidgets
|
||||||
|
|
||||||
|
/// A single‑child container that preserves a fixed aspect ratio.
|
||||||
|
///
|
||||||
|
/// Unlike GTK's built‑in `GtkAspectFrame`, this container overrides
|
||||||
|
/// `measure()` to report the correct height (`width × ratio`) during
|
||||||
|
/// the layout pass. This makes it work correctly inside `ScrollView`,
|
||||||
|
/// `AdwCarousel`, and other flexible layouts where the parent needs
|
||||||
|
/// to know the child's preferred size.
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// AspectContainer(aspectRatio: 16.0 / 9.0) {
|
||||||
|
/// .child {
|
||||||
|
/// Picture()
|
||||||
|
/// .data(imageData)
|
||||||
|
/// .halign(.fill)
|
||||||
|
/// .hexpand()
|
||||||
|
/// }
|
||||||
|
/// .maxWidth(800)
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Ported 1:1 from the Gelata project's `AspectRatioContainer` Rust widget.
|
||||||
|
public struct AspectContainer: Widget {
|
||||||
|
|
||||||
|
#if exposeGeneratedAppearUpdateFunctions
|
||||||
|
public var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = []
|
||||||
|
public var appearFunctions: [(ViewStorage, WidgetData) -> Void] = []
|
||||||
|
#else
|
||||||
|
var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = []
|
||||||
|
var appearFunctions: [(ViewStorage, WidgetData) -> Void] = []
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// The child widget.
|
||||||
|
var child: Body?
|
||||||
|
/// The desired width‑to‑height ratio.
|
||||||
|
var aspectRatio: Float
|
||||||
|
/// An optional maximum width in pixels (0 = no limit).
|
||||||
|
var maxWidth: Int?
|
||||||
|
|
||||||
|
/// Create an aspect‑ratio container.
|
||||||
|
/// - Parameter aspectRatio: Desired `width / height` ratio.
|
||||||
|
public init(aspectRatio: Float) {
|
||||||
|
self.aspectRatio = aspectRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget
|
||||||
|
|
||||||
|
public func container<Data>(data: WidgetData, type: Data.Type) -> ViewStorage
|
||||||
|
where Data: ViewRenderData {
|
||||||
|
let storage = ViewStorage(aspect_container_new(aspectRatio)?.opaque())
|
||||||
|
for function in appearFunctions {
|
||||||
|
function(storage, data)
|
||||||
|
}
|
||||||
|
if let childStorage = child?.storage(data: data, type: type) {
|
||||||
|
storage.content["child"] = [childStorage]
|
||||||
|
gtk_widget_set_parent(
|
||||||
|
childStorage.opaquePointer?.cast(),
|
||||||
|
storage.opaquePointer?.cast())
|
||||||
|
}
|
||||||
|
return storage
|
||||||
|
}
|
||||||
|
|
||||||
|
public func update<Data>(
|
||||||
|
_ storage: ViewStorage,
|
||||||
|
data: WidgetData,
|
||||||
|
updateProperties: Bool,
|
||||||
|
type: Data.Type
|
||||||
|
) where Data: ViewRenderData {
|
||||||
|
storage.modify { widget in
|
||||||
|
if let childStorage = storage.content["child"]?.first {
|
||||||
|
child?.updateStorage(
|
||||||
|
childStorage,
|
||||||
|
data: data,
|
||||||
|
updateProperties: updateProperties,
|
||||||
|
type: type
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if let maxWidth,
|
||||||
|
updateProperties,
|
||||||
|
(storage.previousState as? Self)?.maxWidth != maxWidth
|
||||||
|
{
|
||||||
|
aspect_container_set_max_width(widget, maxWidth.cInt)
|
||||||
|
}
|
||||||
|
if updateProperties,
|
||||||
|
(storage.previousState as? Self)?.aspectRatio != aspectRatio
|
||||||
|
{
|
||||||
|
aspect_container_set_aspect_ratio(widget, aspectRatio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for function in updateFunctions {
|
||||||
|
function(storage, data, updateProperties)
|
||||||
|
}
|
||||||
|
if updateProperties {
|
||||||
|
storage.previousState = self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Modifiers
|
||||||
|
|
||||||
|
extension AspectContainer {
|
||||||
|
|
||||||
|
/// Set the child widget.
|
||||||
|
public func child(@ViewBuilder _ child: () -> Body) -> Self {
|
||||||
|
modify { $0.child = child() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set an optional maximum width in pixels.
|
||||||
|
/// When set to a positive value, the container's width is clamped
|
||||||
|
/// before computing the height from the aspect ratio.
|
||||||
|
public func maxWidth(_ maxWidth: Int?) -> Self {
|
||||||
|
modify { $0.maxWidth = maxWidth }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
import Adwaita
|
import Adwaita
|
||||||
import Foundation
|
import Foundation
|
||||||
import LuminateCore
|
import LuminateCore
|
||||||
|
import LuminateDI
|
||||||
|
|
||||||
struct EpisodeList: View {
|
struct EpisodeList: View {
|
||||||
|
|
||||||
|
|
@ -29,7 +30,7 @@ struct EpisodeList: View {
|
||||||
var seasonId: String
|
var seasonId: String
|
||||||
var client: JellyfinClient
|
var client: JellyfinClient
|
||||||
var userId: String
|
var userId: String
|
||||||
@State private var episodes: [Components.Schemas.BaseItemDto] = []
|
@State private var episodes: [BaseItemDto] = []
|
||||||
|
|
||||||
var view: Body {
|
var view: Body {
|
||||||
VStack {
|
VStack {
|
||||||
|
|
@ -59,8 +60,10 @@ struct EpisodeList: View {
|
||||||
|
|
||||||
struct EpisodeRow: View {
|
struct EpisodeRow: View {
|
||||||
|
|
||||||
var episode: Components.Schemas.BaseItemDto
|
var episode: BaseItemDto
|
||||||
var client: JellyfinClient
|
var client: JellyfinClient
|
||||||
|
@Injected(\.imageService) var imageService
|
||||||
|
@Injected(\.viewUpdateScheduler) var viewUpdateScheduler
|
||||||
@State private var imageData: Data?
|
@State private var imageData: Data?
|
||||||
|
|
||||||
var view: Body {
|
var view: Body {
|
||||||
|
|
@ -105,15 +108,16 @@ struct EpisodeRow: View {
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
guard
|
guard
|
||||||
let url = await client.imageURL(
|
let url = client.imageURL(
|
||||||
itemId: itemId,
|
itemId: itemId,
|
||||||
imageType: .primary,
|
imageType: .primary,
|
||||||
tag: tag,
|
tag: tag,
|
||||||
maxWidth: 200
|
maxWidth: 200
|
||||||
)
|
)
|
||||||
else { return }
|
else { return }
|
||||||
let service = ImageService()
|
let data = try? await imageService.loadImage(url: url)
|
||||||
imageData = try? await service.loadImage(url: url)
|
_imageData.rawValue = data
|
||||||
|
viewUpdateScheduler.scheduleFlush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
232
Sources/LuminateUI/Components/FlowGrid.swift
Normal file
232
Sources/LuminateUI/Components/FlowGrid.swift
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
//
|
||||||
|
// FlowGrid.swift
|
||||||
|
//
|
||||||
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//
|
||||||
|
|
||||||
|
import Adwaita
|
||||||
|
import CGtkWidgets
|
||||||
|
|
||||||
|
/// Controls how the last row is aligned when it doesn't fill the width.
|
||||||
|
public enum FlowGridJustifySetting: Int {
|
||||||
|
|
||||||
|
/// Last row items are aligned to the start.
|
||||||
|
case start
|
||||||
|
/// Last row items are centered.
|
||||||
|
case center
|
||||||
|
/// Last row items are aligned to the end.
|
||||||
|
case end
|
||||||
|
|
||||||
|
var cValue: FlowGridJustify {
|
||||||
|
switch self {
|
||||||
|
case .start: FLOW_GRID_JUSTIFY_START
|
||||||
|
case .center: FLOW_GRID_JUSTIFY_CENTER
|
||||||
|
case .end: FLOW_GRID_JUSTIFY_END
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reflowing grid that arranges children in columns.
|
||||||
|
///
|
||||||
|
/// Unlike `GtkFlowBox`, this widget re‑measures children during every layout
|
||||||
|
/// pass using the actual column width. This avoids stale size caches and
|
||||||
|
/// ensures the `AspectContainer` receives the correct `for_size` for its
|
||||||
|
/// height computation.
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// FlowGrid(items, id: \.id) { item in
|
||||||
|
/// HomePosterCell(item: item, navigation: $navigation)
|
||||||
|
/// }
|
||||||
|
/// .columnSpacing(16)
|
||||||
|
/// .rowSpacing(16)
|
||||||
|
/// .halign(.fill)
|
||||||
|
/// ```
|
||||||
|
public struct FlowGrid<Element, Identifier>: Widget where Identifier: Hashable {
|
||||||
|
|
||||||
|
#if exposeGeneratedAppearUpdateFunctions
|
||||||
|
public var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = []
|
||||||
|
public var appearFunctions: [(ViewStorage, WidgetData) -> Void] = []
|
||||||
|
#else
|
||||||
|
var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = []
|
||||||
|
var appearFunctions: [(ViewStorage, WidgetData) -> Void] = []
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// The minimum column width.
|
||||||
|
var minimumSize: Int = 200
|
||||||
|
/// The spacing between columns.
|
||||||
|
var columnSpacing: Int = 0
|
||||||
|
/// The spacing between rows.
|
||||||
|
var rowSpacing: Int = 0
|
||||||
|
/// How the last row is aligned.
|
||||||
|
var justify: FlowGridJustifySetting = .start
|
||||||
|
|
||||||
|
/// The elements to display.
|
||||||
|
var elements: [Element]
|
||||||
|
/// The content builder.
|
||||||
|
var content: (Element) -> Body
|
||||||
|
/// The identifier key path.
|
||||||
|
var id: KeyPath<Element, Identifier>
|
||||||
|
|
||||||
|
/// Initialize `FlowGrid`.
|
||||||
|
public init(
|
||||||
|
_ elements: [Element],
|
||||||
|
id: KeyPath<Element, Identifier>,
|
||||||
|
@ViewBuilder content: @escaping (Element) -> Body
|
||||||
|
) {
|
||||||
|
self.elements = elements
|
||||||
|
self.content = content
|
||||||
|
self.id = id
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Widget
|
||||||
|
|
||||||
|
public func container<Data>(data: WidgetData, type: Data.Type) -> ViewStorage
|
||||||
|
where Data: ViewRenderData {
|
||||||
|
let storage = ViewStorage(
|
||||||
|
flow_grid_new(minimumSize.cInt, columnSpacing.cInt, rowSpacing.cInt)?.opaque()
|
||||||
|
)
|
||||||
|
for function in appearFunctions {
|
||||||
|
function(storage, data)
|
||||||
|
}
|
||||||
|
return storage
|
||||||
|
}
|
||||||
|
|
||||||
|
public func update<Data>(
|
||||||
|
_ storage: ViewStorage,
|
||||||
|
data: WidgetData,
|
||||||
|
updateProperties: Bool,
|
||||||
|
type: Data.Type
|
||||||
|
) where Data: ViewRenderData {
|
||||||
|
storage.modify { widget in
|
||||||
|
|
||||||
|
// --- Properties ---
|
||||||
|
if updateProperties,
|
||||||
|
(storage.previousState as? Self)?.minimumSize != minimumSize
|
||||||
|
{
|
||||||
|
flow_grid_set_minimum_size(widget, minimumSize.cInt)
|
||||||
|
}
|
||||||
|
if updateProperties,
|
||||||
|
(storage.previousState as? Self)?.columnSpacing != columnSpacing
|
||||||
|
{
|
||||||
|
flow_grid_set_column_spacing(widget, columnSpacing.cInt)
|
||||||
|
}
|
||||||
|
if updateProperties,
|
||||||
|
(storage.previousState as? Self)?.rowSpacing != rowSpacing
|
||||||
|
{
|
||||||
|
flow_grid_set_row_spacing(widget, rowSpacing.cInt)
|
||||||
|
}
|
||||||
|
if updateProperties,
|
||||||
|
(storage.previousState as? Self)?.justify != justify
|
||||||
|
{
|
||||||
|
flow_grid_set_justify(widget, justify.cValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Children ---
|
||||||
|
var contentStorage: [ViewStorage] = storage.content[.mainContent] ?? []
|
||||||
|
let oldElements = storage.fields["element"] as? [Element] ?? []
|
||||||
|
|
||||||
|
var oldByID: [Identifier: ViewStorage] = [:]
|
||||||
|
for (i, oldElement) in oldElements.enumerated() where i < contentStorage.count {
|
||||||
|
oldByID[oldElement[keyPath: id]] = contentStorage[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
var newContentStorage: [ViewStorage] = []
|
||||||
|
var lastChild: OpaquePointer?
|
||||||
|
|
||||||
|
for element in elements {
|
||||||
|
let elementID = element[keyPath: id]
|
||||||
|
if let existingStorage = oldByID.removeValue(forKey: elementID) {
|
||||||
|
newContentStorage.append(existingStorage)
|
||||||
|
lastChild = existingStorage.opaquePointer
|
||||||
|
} else {
|
||||||
|
let child = content(element).storage(data: data, type: type)
|
||||||
|
gtk_widget_set_parent(child.opaquePointer?.cast(), widget?.cast())
|
||||||
|
newContentStorage.append(child)
|
||||||
|
lastChild = child.opaquePointer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (_, staleStorage) in oldByID {
|
||||||
|
gtk_widget_unparent(staleStorage.opaquePointer?.cast())
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.fields["element"] = elements
|
||||||
|
storage.content[.mainContent] = newContentStorage
|
||||||
|
|
||||||
|
for (index, element) in elements.enumerated() {
|
||||||
|
content(element).updateStorage(
|
||||||
|
newContentStorage[index],
|
||||||
|
data: data,
|
||||||
|
updateProperties: updateProperties,
|
||||||
|
type: type
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for function in updateFunctions {
|
||||||
|
function(storage, data, updateProperties)
|
||||||
|
}
|
||||||
|
if updateProperties {
|
||||||
|
storage.previousState = self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Modifiers
|
||||||
|
|
||||||
|
extension FlowGrid {
|
||||||
|
|
||||||
|
/// The minimum column width in pixels.
|
||||||
|
public func minimumSize(_ minimumSize: Int) -> Self {
|
||||||
|
modify { $0.minimumSize = minimumSize }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The spacing between columns in pixels.
|
||||||
|
public func columnSpacing(_ columnSpacing: Int) -> Self {
|
||||||
|
modify { $0.columnSpacing = columnSpacing }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The spacing between rows in pixels.
|
||||||
|
public func rowSpacing(_ rowSpacing: Int) -> Self {
|
||||||
|
modify { $0.rowSpacing = rowSpacing }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the last row is aligned.
|
||||||
|
public func justify(_ justify: FlowGridJustifySetting) -> Self {
|
||||||
|
modify { $0.justify = justify }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Convenience Initializer for Identifiable Elements
|
||||||
|
|
||||||
|
extension FlowGrid where Element: Identifiable, Identifier == Element.ID {
|
||||||
|
|
||||||
|
/// Initialize `FlowGrid` with identifiable elements.
|
||||||
|
public init(
|
||||||
|
_ elements: [Element],
|
||||||
|
@ViewBuilder content: @escaping (Element) -> Body
|
||||||
|
) {
|
||||||
|
self.elements = elements
|
||||||
|
self.content = content
|
||||||
|
self.id = \.id
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -26,78 +26,141 @@ import LuminateDI
|
||||||
|
|
||||||
struct HomePosterCell: View {
|
struct HomePosterCell: View {
|
||||||
|
|
||||||
var item: Components.Schemas.BaseItemDto
|
let itemId: String?
|
||||||
@Injected(\.client) var client
|
let title: String?
|
||||||
@Injected(\.pageAnimationTracker) var pageAnimationTracker
|
let subtitle: String?
|
||||||
|
let width: Int
|
||||||
|
let imageAspectRatio: Float
|
||||||
|
let onClick: (() -> Void)?
|
||||||
|
|
||||||
|
@Injected(\.client) private var client
|
||||||
|
@Injected(\.imageService) private var imageService
|
||||||
|
@Injected(\.viewUpdateScheduler) private var viewUpdateScheduler
|
||||||
|
|
||||||
@State private var imageData: Data?
|
@State private var imageData: Data?
|
||||||
|
|
||||||
|
private struct Constants {
|
||||||
|
static let padding = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
itemId: String? = nil,
|
||||||
|
title: String? = nil,
|
||||||
|
subtitle: String? = nil,
|
||||||
|
width: Int = 200,
|
||||||
|
imageAspectRatio: Float = 1.5,
|
||||||
|
onClick: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.itemId = itemId
|
||||||
|
self.title = title
|
||||||
|
self.subtitle = subtitle
|
||||||
|
self.width = width
|
||||||
|
self.imageAspectRatio = imageAspectRatio
|
||||||
|
self.onClick = onClick
|
||||||
|
}
|
||||||
|
|
||||||
var view: Body {
|
var view: Body {
|
||||||
VStack {
|
Bin {
|
||||||
if let data = imageData {
|
VStack(spacing: showTextSection ? 6 : 0) {
|
||||||
Picture()
|
imageSection
|
||||||
.contentFit(.cover)
|
if showTextSection {
|
||||||
.data(data)
|
textSection
|
||||||
.frame(minWidth: 200, minHeight: 300)
|
|
||||||
.frame(maxWidth: 200)
|
|
||||||
.frame(maxHeight: 300)
|
|
||||||
} else {
|
|
||||||
Box(spacing: 0) {}
|
|
||||||
.frame(minWidth: 200, minHeight: 300)
|
|
||||||
.frame(maxWidth: 200)
|
|
||||||
.frame(maxHeight: 300)
|
|
||||||
.card()
|
|
||||||
}
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
if let title = item.name, !title.isEmpty {
|
|
||||||
Text(item.name ?? "")
|
|
||||||
.ellipsize()
|
|
||||||
.heading()
|
|
||||||
.halign(.center)
|
|
||||||
.frame(maxWidth: 200)
|
|
||||||
}
|
|
||||||
let subtitle = item.yearString
|
|
||||||
if !subtitle.isEmpty {
|
|
||||||
Text(subtitle)
|
|
||||||
.ellipsize()
|
|
||||||
.caption()
|
|
||||||
.dimLabel()
|
|
||||||
.halign(.center)
|
|
||||||
.frame(maxWidth: 200)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(6, .vertical)
|
.padding(padding)
|
||||||
.padding(12, .horizontal)
|
|
||||||
}
|
}
|
||||||
|
.onClick(handler: onClick ?? {})
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Idle {
|
Idle {
|
||||||
loadImage()
|
loadImage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overflow(.hidden)
|
.frame(minWidth: width)
|
||||||
|
.halign(.fill)
|
||||||
|
.valign(.start)
|
||||||
|
.hexpand(false)
|
||||||
|
.style("activatable")
|
||||||
.card()
|
.card()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadImage() {
|
@ViewBuilder
|
||||||
guard let tag = item.primaryImageTag,
|
private var imageSection: Body {
|
||||||
let itemId = item.seriesId ?? item.id
|
AspectContainer(aspectRatio: imageAspectRatio)
|
||||||
else { return }
|
.child {
|
||||||
Task {
|
image
|
||||||
guard
|
.halign(.fill)
|
||||||
let url = await client.imageURL(
|
.hexpand()
|
||||||
itemId: itemId,
|
.overflow(.hidden)
|
||||||
imageType: .primary,
|
.card()
|
||||||
tag: tag,
|
|
||||||
maxWidth: 400
|
|
||||||
)
|
|
||||||
else { return }
|
|
||||||
let service = ImageService()
|
|
||||||
let data = try? await service.loadImage(url: url)
|
|
||||||
|
|
||||||
if pageAnimationTracker.isAnimating {
|
|
||||||
_imageData.rawValue = data
|
|
||||||
} else {
|
|
||||||
imageData = data
|
|
||||||
}
|
}
|
||||||
|
.frame(minWidth: width - (padding * 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var image: Body {
|
||||||
|
if let imageData {
|
||||||
|
Picture()
|
||||||
|
.contentFit(.cover)
|
||||||
|
.data(imageData)
|
||||||
|
.valign(.fill)
|
||||||
|
.halign(.fill)
|
||||||
|
.vexpand()
|
||||||
|
.hexpand()
|
||||||
|
.transition(.crossfade)
|
||||||
|
} else {
|
||||||
|
Spinner()
|
||||||
|
.frame(minWidth: 64, minHeight: 64)
|
||||||
|
.transition(.crossfade)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var textSection: Body {
|
||||||
|
Bin {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
if let title, !title.isEmpty {
|
||||||
|
Text(title)
|
||||||
|
.maxWidthChars(0)
|
||||||
|
.ellipsize()
|
||||||
|
.heading()
|
||||||
|
}
|
||||||
|
if let subtitle, !subtitle.isEmpty {
|
||||||
|
Text(subtitle)
|
||||||
|
.maxWidthChars(0)
|
||||||
|
.ellipsize()
|
||||||
|
.caption()
|
||||||
|
.dimLabel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.valign(.center)
|
||||||
|
}
|
||||||
|
.frame(minHeight: 40)
|
||||||
|
.hexpand()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var showTextSection: Bool {
|
||||||
|
(title?.isEmpty == false) && (subtitle?.isEmpty == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var padding: Int {
|
||||||
|
showTextSection ? Constants.padding : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadImage() {
|
||||||
|
guard let itemId else { return }
|
||||||
|
guard
|
||||||
|
let url = client.imageURL(
|
||||||
|
itemId: itemId,
|
||||||
|
imageType: .primary,
|
||||||
|
maxWidth: width.cInt * 2
|
||||||
|
)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
Task {
|
||||||
|
let data = try? await imageService.loadImage(url: url)
|
||||||
|
|
||||||
|
_imageData.rawValue = data
|
||||||
|
viewUpdateScheduler.scheduleFlush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,78 +20,55 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Adwaita
|
import Adwaita
|
||||||
|
import Foundation
|
||||||
import LuminateCore
|
import LuminateCore
|
||||||
|
|
||||||
public struct ItemGrid: View {
|
public struct ItemGrid: View {
|
||||||
|
|
||||||
var client: JellyfinClient
|
public var items: [BaseItemDto]
|
||||||
var userId: String
|
public var type: DisplayType
|
||||||
var parentId: String?
|
public var title: String?
|
||||||
var includeItemTypes: [Components.Schemas.BaseItemKind]?
|
|
||||||
var title: String?
|
@Binding public var navigation: NavigationStack<Page>
|
||||||
@State private var items: [Components.Schemas.BaseItemDto] = []
|
|
||||||
@State private var isLoading = false
|
|
||||||
private let pageSize: Int32 = 50
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
client: JellyfinClient,
|
items: [BaseItemDto],
|
||||||
userId: String,
|
type: DisplayType = .mixed,
|
||||||
parentId: String? = nil,
|
navigation: Binding<NavigationStack<Page>>,
|
||||||
includeItemTypes: [Components.Schemas.BaseItemKind]? = nil,
|
|
||||||
title: String? = nil
|
title: String? = nil
|
||||||
) {
|
) {
|
||||||
self.client = client
|
self.items = items
|
||||||
self.userId = userId
|
self.type = type
|
||||||
self.parentId = parentId
|
_navigation = navigation
|
||||||
self.includeItemTypes = includeItemTypes
|
|
||||||
self.title = title
|
self.title = title
|
||||||
}
|
}
|
||||||
|
|
||||||
public var view: Body {
|
public var view: Body {
|
||||||
VStack {
|
VStack(spacing: 16) {
|
||||||
if let title {
|
Text(title ?? "Libraries")
|
||||||
Text(title)
|
.title3()
|
||||||
.title2()
|
.halign(.start)
|
||||||
.halign(.start)
|
.padding(10, .horizontal)
|
||||||
.padding()
|
FlowGrid(items, id: \.id) { item in
|
||||||
}
|
HomePosterCell(
|
||||||
if isLoading {
|
itemId: type.itemId(for: item),
|
||||||
Spinner()
|
title: type.title(for: item),
|
||||||
} else {
|
subtitle: type.subtitle(for: item),
|
||||||
ScrollView {
|
width: type.itemWidth(),
|
||||||
FlowBox(items) { item in
|
imageAspectRatio: type.aspectRatio
|
||||||
PosterCell(item: item, client: client)
|
) {
|
||||||
|
if let childDisplayType = item.childDisplayType {
|
||||||
|
navigation.push(.item(item: item, type: childDisplayType))
|
||||||
|
}
|
||||||
|
if item.type == .movie {
|
||||||
|
navigation.push(.movieDetail(item: item))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.columnSpacing(16)
|
||||||
.onAppear {
|
.rowSpacing(16)
|
||||||
loadItems()
|
.minimumSize(type.itemWidth())
|
||||||
}
|
.halign(.fill)
|
||||||
}
|
|
||||||
|
|
||||||
private func loadItems() {
|
|
||||||
isLoading = true
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
let result = try await client.getItems(
|
|
||||||
userId: userId,
|
|
||||||
parentId: parentId,
|
|
||||||
includeItemTypes: includeItemTypes,
|
|
||||||
fields: [.overview, .genres, .people, .mediaSources],
|
|
||||||
sortBy: [.sortName],
|
|
||||||
sortOrder: [.ascending],
|
|
||||||
startIndex: 0,
|
|
||||||
limit: pageSize,
|
|
||||||
recursive: true
|
|
||||||
)
|
|
||||||
await MainActor.run {
|
|
||||||
items = result.items ?? []
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await MainActor.run { isLoading = false }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
//
|
|
||||||
// LibraryGrid.swift
|
|
||||||
//
|
|
||||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
//
|
|
||||||
|
|
||||||
import Adwaita
|
|
||||||
import Foundation
|
|
||||||
import LuminateCore
|
|
||||||
|
|
||||||
public struct LibraryGrid: View {
|
|
||||||
|
|
||||||
public var libraries: [Components.Schemas.BaseItemDto]
|
|
||||||
@Binding public var navigation: NavigationStack<Page>
|
|
||||||
public var title: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
libraries: [Components.Schemas.BaseItemDto],
|
|
||||||
navigation: Binding<NavigationStack<Page>>,
|
|
||||||
title: String? = nil
|
|
||||||
) {
|
|
||||||
self.libraries = libraries
|
|
||||||
_navigation = navigation
|
|
||||||
self.title = title
|
|
||||||
}
|
|
||||||
|
|
||||||
public var view: Body {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Text(title ?? "Libraries")
|
|
||||||
.title3()
|
|
||||||
.halign(.start)
|
|
||||||
.padding(10, .horizontal)
|
|
||||||
FlowBox(libraries) { library in
|
|
||||||
HomePosterCell(item: library)
|
|
||||||
}
|
|
||||||
.columnSpacing(16)
|
|
||||||
.rowSpacing(16)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -22,22 +22,29 @@
|
||||||
import Adwaita
|
import Adwaita
|
||||||
import Foundation
|
import Foundation
|
||||||
import LuminateCore
|
import LuminateCore
|
||||||
|
import LuminateDI
|
||||||
|
|
||||||
public struct MediaRow: View {
|
public struct MediaRow: View {
|
||||||
|
|
||||||
public var title: String
|
public var title: String
|
||||||
public var items: [Components.Schemas.BaseItemDto]
|
public var items: [BaseItemDto]
|
||||||
@Binding public var navigation: NavigationStack<Page>
|
public var type: DisplayType
|
||||||
public var onSeeAll: (() -> Void)?
|
public var onSeeAll: (() -> Void)?
|
||||||
|
|
||||||
|
@Binding public var navigation: NavigationStack<Page>
|
||||||
|
|
||||||
|
@Injected(\.client) private var client
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
title: String,
|
title: String,
|
||||||
items: [Components.Schemas.BaseItemDto],
|
items: [BaseItemDto],
|
||||||
|
type: DisplayType = .mixed,
|
||||||
navigation: Binding<NavigationStack<Page>>,
|
navigation: Binding<NavigationStack<Page>>,
|
||||||
onSeeAll: (() -> Void)? = nil
|
onSeeAll: @escaping () -> Void
|
||||||
) {
|
) {
|
||||||
self.title = title
|
self.title = title
|
||||||
self.items = items
|
self.items = items
|
||||||
|
self.type = type
|
||||||
_navigation = navigation
|
_navigation = navigation
|
||||||
self.onSeeAll = onSeeAll
|
self.onSeeAll = onSeeAll
|
||||||
}
|
}
|
||||||
|
|
@ -47,26 +54,41 @@ public struct MediaRow: View {
|
||||||
HStack {
|
HStack {
|
||||||
Text(title)
|
Text(title)
|
||||||
.title3()
|
.title3()
|
||||||
|
.halign(.start)
|
||||||
/// Poor man's `Spacer()`
|
|
||||||
Bin()
|
|
||||||
.hexpand()
|
|
||||||
|
|
||||||
if let onSeeAll {
|
if let onSeeAll {
|
||||||
Button("See All") {
|
Button("See All") {
|
||||||
onSeeAll()
|
onSeeAll()
|
||||||
}
|
}
|
||||||
|
.halign(.end)
|
||||||
|
.hexpand()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.halign(.fill)
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
ForEach(items, horizontal: true) { item in
|
ForEach(items, horizontal: true) { item in
|
||||||
HomePosterCell(item: item)
|
HomePosterCell(
|
||||||
.padding(16, .trailing)
|
itemId: type.itemId(for: item),
|
||||||
|
title: type.title(for: item),
|
||||||
|
subtitle: type.subtitle(for: item),
|
||||||
|
width: type.itemWidth(),
|
||||||
|
imageAspectRatio: type.aspectRatio
|
||||||
|
) {
|
||||||
|
if let childDisplayType = item.childDisplayType {
|
||||||
|
navigation.push(.item(item: item, type: childDisplayType))
|
||||||
|
}
|
||||||
|
if item.type == .movie {
|
||||||
|
navigation.push(.movieDetail(item: item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16, .trailing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.vscrollbarPolicy(.never)
|
.vscrollbarPolicy(.never)
|
||||||
.hscrollbarPolicy(.external)
|
.hscrollbarPolicy(.external)
|
||||||
|
.style("undershoot-start")
|
||||||
|
.style("undershoot-end")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,26 +22,26 @@
|
||||||
import Adwaita
|
import Adwaita
|
||||||
import Foundation
|
import Foundation
|
||||||
import LuminateCore
|
import LuminateCore
|
||||||
|
import LuminateDI
|
||||||
|
|
||||||
struct MovieDetailView: View {
|
public struct MovieDetailView: View {
|
||||||
|
|
||||||
var item: Components.Schemas.BaseItemDto
|
var item: BaseItemDto
|
||||||
var client: JellyfinClient
|
@Injected(\.client) private var client
|
||||||
var userId: String
|
@Injected(\.userId) private var userId
|
||||||
|
@Injected(\.imageService) private var imageService
|
||||||
@State private var isFavorite: Bool
|
@State private var isFavorite: Bool
|
||||||
@State private var isPlayed: Bool
|
@State private var isPlayed: Bool
|
||||||
@State private var similarItems: [Components.Schemas.BaseItemDto] = []
|
@State private var similarItems: [BaseItemDto] = []
|
||||||
@State private var backdropData: Data?
|
@State private var backdropData: Data?
|
||||||
|
|
||||||
init(item: Components.Schemas.BaseItemDto, client: JellyfinClient, userId: String) {
|
public init(for item: BaseItemDto) {
|
||||||
self.item = item
|
self.item = item
|
||||||
self.client = client
|
|
||||||
self.userId = userId
|
|
||||||
_isFavorite = .init(wrappedValue: item.userData?.value1.isFavorite ?? false)
|
_isFavorite = .init(wrappedValue: item.userData?.value1.isFavorite ?? false)
|
||||||
_isPlayed = .init(wrappedValue: item.userData?.value1.played ?? false)
|
_isPlayed = .init(wrappedValue: item.userData?.value1.played ?? false)
|
||||||
}
|
}
|
||||||
|
|
||||||
var view: Body {
|
public var view: Body {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack {
|
VStack {
|
||||||
if let data = backdropData {
|
if let data = backdropData {
|
||||||
|
|
@ -52,7 +52,7 @@ struct MovieDetailView: View {
|
||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
PosterCell(item: item, client: client)
|
PosterCell(item: item)
|
||||||
.frame(minWidth: 200)
|
.frame(minWidth: 200)
|
||||||
.frame(maxWidth: 200)
|
.frame(maxWidth: 200)
|
||||||
VStack {
|
VStack {
|
||||||
|
|
@ -111,7 +111,7 @@ struct MovieDetailView: View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
HStack {
|
HStack {
|
||||||
ForEach(similarItems) { sim in
|
ForEach(similarItems) { sim in
|
||||||
PosterCell(item: sim, client: client)
|
PosterCell(item: sim)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -131,12 +131,11 @@ struct MovieDetailView: View {
|
||||||
guard let tag = item.backdropImageTag, let itemId = item.id else { return }
|
guard let tag = item.backdropImageTag, let itemId = item.id else { return }
|
||||||
Task {
|
Task {
|
||||||
guard
|
guard
|
||||||
let url = await client.imageURL(
|
let url = client.imageURL(
|
||||||
itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920
|
itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920
|
||||||
)
|
)
|
||||||
else { return }
|
else { return }
|
||||||
let service = ImageService()
|
backdropData = try? await imageService.loadImage(url: url)
|
||||||
backdropData = try? await service.loadImage(url: url)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,29 +149,29 @@ struct MovieDetailView: View {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
recursive: true
|
recursive: true
|
||||||
)
|
)
|
||||||
await MainActor.run { similarItems = result?.items ?? [] }
|
similarItems = result?.items ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleFavorite() {
|
private func toggleFavorite() {
|
||||||
Task {
|
Task {
|
||||||
if isFavorite {
|
if isFavorite {
|
||||||
try? await client.unmarkFavoriteItem(itemId: item.id ?? "", userId: userId)
|
_ = try? await client.unmarkFavoriteItem(itemId: item.id ?? "", userId: userId)
|
||||||
} else {
|
} else {
|
||||||
try? await client.markFavoriteItem(itemId: item.id ?? "", userId: userId)
|
_ = try? await client.markFavoriteItem(itemId: item.id ?? "", userId: userId)
|
||||||
}
|
}
|
||||||
await MainActor.run { isFavorite.toggle() }
|
isFavorite.toggle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func togglePlayed() {
|
private func togglePlayed() {
|
||||||
Task {
|
Task {
|
||||||
if isPlayed {
|
if isPlayed {
|
||||||
try? await client.markUnplayedItem(itemId: item.id ?? "", userId: userId)
|
_ = try? await client.markUnplayedItem(itemId: item.id ?? "", userId: userId)
|
||||||
} else {
|
} else {
|
||||||
try? await client.markPlayedItem(itemId: item.id ?? "", userId: userId)
|
_ = try? await client.markPlayedItem(itemId: item.id ?? "", userId: userId)
|
||||||
}
|
}
|
||||||
await MainActor.run { isPlayed.toggle() }
|
isPlayed.toggle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import Adwaita
|
||||||
import LuminateCore
|
import LuminateCore
|
||||||
|
|
||||||
struct PersonCell: View {
|
struct PersonCell: View {
|
||||||
var person: Components.Schemas.BaseItemPerson
|
var person: BaseItemPerson
|
||||||
var view: Body {
|
var view: Body {
|
||||||
VStack {
|
VStack {
|
||||||
Avatar(showInitials: false, size: 60)
|
Avatar(showInitials: false, size: 60)
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,10 @@ import LuminateDI
|
||||||
|
|
||||||
struct PosterCell: View {
|
struct PosterCell: View {
|
||||||
|
|
||||||
var item: Components.Schemas.BaseItemDto
|
var item: BaseItemDto
|
||||||
var client: JellyfinClient
|
@Injected(\.client) private var client: JellyfinClient
|
||||||
@Injected(\.pageAnimationTracker) var pageAnimationTracker
|
@Injected(\.imageService) private var imageService
|
||||||
|
@Injected(\.viewUpdateScheduler) private var viewUpdateScheduler
|
||||||
@State private var imageData: Data?
|
@State private var imageData: Data?
|
||||||
|
|
||||||
var view: Body {
|
var view: Body {
|
||||||
|
|
@ -68,14 +69,10 @@ struct PosterCell: View {
|
||||||
maxWidth: 300
|
maxWidth: 300
|
||||||
)
|
)
|
||||||
guard let url else { return }
|
guard let url else { return }
|
||||||
let service = ImageService()
|
let data = try? await imageService.loadImage(url: url)
|
||||||
let data = try? await service.loadImage(url: url)
|
|
||||||
|
|
||||||
if pageAnimationTracker.isAnimating {
|
_imageData.rawValue = data
|
||||||
_imageData.rawValue = data
|
viewUpdateScheduler.scheduleFlush()
|
||||||
} else {
|
|
||||||
imageData = data
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ struct SearchView: View {
|
||||||
var client: JellyfinClient
|
var client: JellyfinClient
|
||||||
var userId: String
|
var userId: String
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var results: [Components.Schemas.SearchHint] = []
|
@State private var results: [SearchHint] = []
|
||||||
@State private var isSearching = false
|
@State private var isSearching = false
|
||||||
|
|
||||||
var view: Body {
|
var view: Body {
|
||||||
|
|
@ -73,15 +73,16 @@ struct SearchView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Components.Schemas.SearchHint: Identifiable {
|
extension SearchHint: Identifiable {
|
||||||
public var id: String { id ?? itemId ?? String(describing: self) }
|
public var id: String { id ?? itemId ?? String(describing: self) }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SearchResultRow: View {
|
struct SearchResultRow: View {
|
||||||
|
|
||||||
var hint: Components.Schemas.SearchHint
|
var hint: SearchHint
|
||||||
var client: JellyfinClient
|
var client: JellyfinClient
|
||||||
@Injected(\.pageAnimationTracker) var pageAnimationTracker
|
@Injected(\.viewUpdateScheduler) var viewUpdateScheduler
|
||||||
|
@Injected(\.imageService) var imageService
|
||||||
@State private var imageData: Data?
|
@State private var imageData: Data?
|
||||||
|
|
||||||
var view: Body {
|
var view: Body {
|
||||||
|
|
@ -138,14 +139,10 @@ struct SearchResultRow: View {
|
||||||
itemId: itemId, imageType: .primary, tag: tag, maxWidth: 160
|
itemId: itemId, imageType: .primary, tag: tag, maxWidth: 160
|
||||||
)
|
)
|
||||||
else { return }
|
else { return }
|
||||||
let service = ImageService()
|
let data = try? await imageService.loadImage(url: url)
|
||||||
let data = try? await service.loadImage(url: url)
|
|
||||||
|
|
||||||
if pageAnimationTracker.isAnimating {
|
_imageData.rawValue = data
|
||||||
_imageData.rawValue = data
|
viewUpdateScheduler.scheduleFlush()
|
||||||
} else {
|
|
||||||
imageData = data
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,15 @@
|
||||||
import Adwaita
|
import Adwaita
|
||||||
import Foundation
|
import Foundation
|
||||||
import LuminateCore
|
import LuminateCore
|
||||||
|
import LuminateDI
|
||||||
|
|
||||||
struct TVShowView: View {
|
struct TVShowView: View {
|
||||||
|
|
||||||
var item: Components.Schemas.BaseItemDto
|
var item: BaseItemDto
|
||||||
var client: JellyfinClient
|
var client: JellyfinClient
|
||||||
var userId: String
|
var userId: String
|
||||||
@State private var seasons: [Components.Schemas.BaseItemDto] = []
|
@Injected(\.imageService) var imageService
|
||||||
|
@State private var seasons: [BaseItemDto] = []
|
||||||
@State private var selectedSeasonId: String?
|
@State private var selectedSeasonId: String?
|
||||||
@State private var backdropData: Data?
|
@State private var backdropData: Data?
|
||||||
|
|
||||||
|
|
@ -43,7 +45,7 @@ struct TVShowView: View {
|
||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
PosterCell(item: item, client: client)
|
PosterCell(item: item)
|
||||||
.frame(minWidth: 200)
|
.frame(minWidth: 200)
|
||||||
.frame(maxWidth: 200)
|
.frame(maxWidth: 200)
|
||||||
VStack {
|
VStack {
|
||||||
|
|
@ -106,8 +108,7 @@ struct TVShowView: View {
|
||||||
itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920
|
itemId: itemId, imageType: .backdrop, tag: tag, maxWidth: 1920
|
||||||
)
|
)
|
||||||
else { return }
|
else { return }
|
||||||
let service = ImageService()
|
backdropData = try? await imageService.loadImage(url: url)
|
||||||
backdropData = try? await service.loadImage(url: url)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
430
Sources/LuminateUI/Components/WrapBox.swift
Normal file
430
Sources/LuminateUI/Components/WrapBox.swift
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
//
|
||||||
|
// WrapBox.swift
|
||||||
|
//
|
||||||
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//
|
||||||
|
|
||||||
|
import Adwaita
|
||||||
|
import CAdw
|
||||||
|
|
||||||
|
// MARK: - Swift Enum Wrappers for C Enums
|
||||||
|
|
||||||
|
/// Controls how children are justified within each line.
|
||||||
|
public enum JustifyMode {
|
||||||
|
|
||||||
|
/// No justification.
|
||||||
|
case none
|
||||||
|
/// Children are stretched to fill the line.
|
||||||
|
case fill
|
||||||
|
/// Extra space is distributed evenly between children.
|
||||||
|
case spread
|
||||||
|
|
||||||
|
var cValue: AdwJustifyMode {
|
||||||
|
switch self {
|
||||||
|
case .none: ADW_JUSTIFY_NONE
|
||||||
|
case .fill: ADW_JUSTIFY_FILL
|
||||||
|
case .spread: ADW_JUSTIFY_SPREAD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controls the packing direction.
|
||||||
|
public enum PackDirection {
|
||||||
|
|
||||||
|
/// Children are packed from start to end.
|
||||||
|
case startToEnd
|
||||||
|
/// Children are packed from end to start.
|
||||||
|
case endToStart
|
||||||
|
|
||||||
|
var cValue: AdwPackDirection {
|
||||||
|
switch self {
|
||||||
|
case .startToEnd: ADW_PACK_START_TO_END
|
||||||
|
case .endToStart: ADW_PACK_END_TO_START
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Controls the wrapping policy.
|
||||||
|
public enum WrapPolicy {
|
||||||
|
|
||||||
|
/// Wrapping occurs at the minimum size.
|
||||||
|
case minimum
|
||||||
|
/// Wrapping occurs at the natural size.
|
||||||
|
case natural
|
||||||
|
|
||||||
|
var cValue: AdwWrapPolicy {
|
||||||
|
switch self {
|
||||||
|
case .minimum: ADW_WRAP_MINIMUM
|
||||||
|
case .natural: ADW_WRAP_NATURAL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Units for spacing and length properties.
|
||||||
|
public enum LengthUnit {
|
||||||
|
|
||||||
|
/// Pixels.
|
||||||
|
case px
|
||||||
|
/// Points.
|
||||||
|
case pt
|
||||||
|
/// Scale-independent pixels.
|
||||||
|
case sp
|
||||||
|
|
||||||
|
var cValue: AdwLengthUnit {
|
||||||
|
switch self {
|
||||||
|
case .px: ADW_LENGTH_UNIT_PX
|
||||||
|
case .pt: ADW_LENGTH_UNIT_PT
|
||||||
|
case .sp: ADW_LENGTH_UNIT_SP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WrapBox Widget
|
||||||
|
|
||||||
|
/// A responsive wrapping container that arranges children in a reflowing grid.
|
||||||
|
///
|
||||||
|
/// `WrapBox` places its children in a horizontal flow, wrapping to the next
|
||||||
|
/// line when the available width is exhausted. It is backed by `AdwWrapBox` from libadwaita.
|
||||||
|
///
|
||||||
|
/// Use inside a `ScrollView` to allow shrinking in both axes.
|
||||||
|
///
|
||||||
|
/// ```swift
|
||||||
|
/// ScrollView {
|
||||||
|
/// WrapBox(items) { item in
|
||||||
|
/// ItemCell(item: item)
|
||||||
|
/// }
|
||||||
|
/// .childSpacing(12)
|
||||||
|
/// .lineSpacing(12)
|
||||||
|
/// .lineHomogeneous(true)
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
public struct WrapBox<Element, Identifier>: AdwaitaWidget where Identifier: Hashable {
|
||||||
|
|
||||||
|
#if exposeGeneratedAppearUpdateFunctions
|
||||||
|
/// Additional update functions for type extensions.
|
||||||
|
public var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = []
|
||||||
|
/// Additional appear functions for type extensions.
|
||||||
|
public var appearFunctions: [(ViewStorage, WidgetData) -> Void] = []
|
||||||
|
#else
|
||||||
|
/// Additional update functions for type extensions.
|
||||||
|
var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = []
|
||||||
|
/// Additional appear functions for type extensions.
|
||||||
|
var appearFunctions: [(ViewStorage, WidgetData) -> Void] = []
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// The amount of space between children.
|
||||||
|
var childSpacing: Int?
|
||||||
|
/// The unit for `childSpacing`.
|
||||||
|
var childSpacingUnit: LengthUnit?
|
||||||
|
/// The packing direction.
|
||||||
|
var packDirection: PackDirection?
|
||||||
|
/// The alignment of children within each line (0.0 to 1.0).
|
||||||
|
var align: Float?
|
||||||
|
/// The justification mode.
|
||||||
|
var justify: JustifyMode?
|
||||||
|
/// Whether to justify the last line.
|
||||||
|
var justifyLastLine: Bool?
|
||||||
|
/// The amount of space between lines.
|
||||||
|
var lineSpacing: Int?
|
||||||
|
/// The unit for `lineSpacing`.
|
||||||
|
var lineSpacingUnit: LengthUnit?
|
||||||
|
/// Whether all lines should be the same size.
|
||||||
|
var lineHomogeneous: Bool?
|
||||||
|
/// The natural length of each line.
|
||||||
|
var naturalLineLength: Int?
|
||||||
|
/// The unit for `naturalLineLength`.
|
||||||
|
var naturalLineLengthUnit: LengthUnit?
|
||||||
|
/// Whether to reverse the wrapping direction.
|
||||||
|
var wrapReverse: Bool?
|
||||||
|
/// The wrapping policy.
|
||||||
|
var wrapPolicy: WrapPolicy?
|
||||||
|
|
||||||
|
/// The dynamic widget elements.
|
||||||
|
var elements: [Element]
|
||||||
|
/// The dynamic widget content.
|
||||||
|
var content: (Element) -> Body
|
||||||
|
/// The dynamic widget identifier key path.
|
||||||
|
var id: KeyPath<Element, Identifier>
|
||||||
|
|
||||||
|
/// Initialize `WrapBox`.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - elements: The elements to display.
|
||||||
|
/// - id: The key path to the element's identifier.
|
||||||
|
/// - content: A view builder for rendering each element.
|
||||||
|
public init(
|
||||||
|
_ elements: [Element],
|
||||||
|
id: KeyPath<Element, Identifier>,
|
||||||
|
@ViewBuilder content: @escaping (Element) -> Body
|
||||||
|
) {
|
||||||
|
self.elements = elements
|
||||||
|
self.content = content
|
||||||
|
self.id = id
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AdwaitaWidget
|
||||||
|
|
||||||
|
public func container<Data>(data: WidgetData, type: Data.Type) -> ViewStorage
|
||||||
|
where Data: ViewRenderData {
|
||||||
|
let storage = ViewStorage(adw_wrap_box_new()?.opaque())
|
||||||
|
for function in appearFunctions {
|
||||||
|
function(storage, data)
|
||||||
|
}
|
||||||
|
return storage
|
||||||
|
}
|
||||||
|
|
||||||
|
public func update<Data>(
|
||||||
|
_ storage: ViewStorage,
|
||||||
|
data: WidgetData,
|
||||||
|
updateProperties: Bool,
|
||||||
|
type: Data.Type
|
||||||
|
) where Data: ViewRenderData {
|
||||||
|
storage.modify { widget in
|
||||||
|
|
||||||
|
// --- Apply property changes ---
|
||||||
|
if let childSpacing, updateProperties,
|
||||||
|
(storage.previousState as? Self)?.childSpacing != childSpacing
|
||||||
|
{
|
||||||
|
adw_wrap_box_set_child_spacing(widget, childSpacing.cInt)
|
||||||
|
}
|
||||||
|
if let childSpacingUnit, updateProperties,
|
||||||
|
(storage.previousState as? Self)?.childSpacingUnit != childSpacingUnit
|
||||||
|
{
|
||||||
|
adw_wrap_box_set_child_spacing_unit(widget, childSpacingUnit.cValue)
|
||||||
|
}
|
||||||
|
if let packDirection, updateProperties,
|
||||||
|
(storage.previousState as? Self)?.packDirection != packDirection
|
||||||
|
{
|
||||||
|
adw_wrap_box_set_pack_direction(widget, packDirection.cValue)
|
||||||
|
}
|
||||||
|
if let align, updateProperties,
|
||||||
|
(storage.previousState as? Self)?.align != align
|
||||||
|
{
|
||||||
|
adw_wrap_box_set_align(widget, align)
|
||||||
|
}
|
||||||
|
if let justify, updateProperties,
|
||||||
|
(storage.previousState as? Self)?.justify != justify
|
||||||
|
{
|
||||||
|
adw_wrap_box_set_justify(widget, justify.cValue)
|
||||||
|
}
|
||||||
|
if let justifyLastLine, updateProperties,
|
||||||
|
(storage.previousState as? Self)?.justifyLastLine != justifyLastLine
|
||||||
|
{
|
||||||
|
adw_wrap_box_set_justify_last_line(widget, justifyLastLine.cBool)
|
||||||
|
}
|
||||||
|
if let lineSpacing, updateProperties,
|
||||||
|
(storage.previousState as? Self)?.lineSpacing != lineSpacing
|
||||||
|
{
|
||||||
|
adw_wrap_box_set_line_spacing(widget, lineSpacing.cInt)
|
||||||
|
}
|
||||||
|
if let lineSpacingUnit, updateProperties,
|
||||||
|
(storage.previousState as? Self)?.lineSpacingUnit != lineSpacingUnit
|
||||||
|
{
|
||||||
|
adw_wrap_box_set_line_spacing_unit(widget, lineSpacingUnit.cValue)
|
||||||
|
}
|
||||||
|
if let lineHomogeneous, updateProperties,
|
||||||
|
(storage.previousState as? Self)?.lineHomogeneous != lineHomogeneous
|
||||||
|
{
|
||||||
|
adw_wrap_box_set_line_homogeneous(widget, lineHomogeneous.cBool)
|
||||||
|
}
|
||||||
|
if let naturalLineLength, updateProperties,
|
||||||
|
(storage.previousState as? Self)?.naturalLineLength != naturalLineLength
|
||||||
|
{
|
||||||
|
adw_wrap_box_set_natural_line_length(widget, naturalLineLength.cInt)
|
||||||
|
}
|
||||||
|
if let naturalLineLengthUnit, updateProperties,
|
||||||
|
(storage.previousState as? Self)?.naturalLineLengthUnit != naturalLineLengthUnit
|
||||||
|
{
|
||||||
|
adw_wrap_box_set_natural_line_length_unit(widget, naturalLineLengthUnit.cValue)
|
||||||
|
}
|
||||||
|
if let wrapReverse, updateProperties,
|
||||||
|
(storage.previousState as? Self)?.wrapReverse != wrapReverse
|
||||||
|
{
|
||||||
|
adw_wrap_box_set_wrap_reverse(widget, wrapReverse.cBool)
|
||||||
|
}
|
||||||
|
if let wrapPolicy, updateProperties,
|
||||||
|
(storage.previousState as? Self)?.wrapPolicy != wrapPolicy
|
||||||
|
{
|
||||||
|
adw_wrap_box_set_wrap_policy(widget, wrapPolicy.cValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Child management ---
|
||||||
|
var contentStorage: [ViewStorage] = storage.content[.mainContent] ?? []
|
||||||
|
let oldElements = storage.fields["element"] as? [Element] ?? []
|
||||||
|
|
||||||
|
var oldByID: [Identifier: ViewStorage] = [:]
|
||||||
|
for (i, oldElement) in oldElements.enumerated() where i < contentStorage.count {
|
||||||
|
oldByID[oldElement[keyPath: id]] = contentStorage[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
var newContentStorage: [ViewStorage] = []
|
||||||
|
var lastChild: OpaquePointer?
|
||||||
|
|
||||||
|
for element in elements {
|
||||||
|
let elementID = element[keyPath: id]
|
||||||
|
if let existingStorage = oldByID.removeValue(forKey: elementID) {
|
||||||
|
newContentStorage.append(existingStorage)
|
||||||
|
if let lastPtr = lastChild {
|
||||||
|
adw_wrap_box_reorder_child_after(
|
||||||
|
widget,
|
||||||
|
existingStorage.opaquePointer?.cast(),
|
||||||
|
lastPtr.cast()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
adw_wrap_box_reorder_child_after(
|
||||||
|
widget,
|
||||||
|
existingStorage.opaquePointer?.cast(),
|
||||||
|
nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
lastChild = existingStorage.opaquePointer
|
||||||
|
} else {
|
||||||
|
let child = content(element).storage(data: data, type: type)
|
||||||
|
if let lastPtr = lastChild {
|
||||||
|
adw_wrap_box_insert_child_after(
|
||||||
|
widget,
|
||||||
|
child.opaquePointer?.cast(),
|
||||||
|
lastPtr.cast()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
adw_wrap_box_prepend(widget, child.opaquePointer?.cast())
|
||||||
|
}
|
||||||
|
newContentStorage.append(child)
|
||||||
|
lastChild = child.opaquePointer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (_, staleStorage) in oldByID {
|
||||||
|
adw_wrap_box_remove(widget, staleStorage.opaquePointer?.cast())
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.fields["element"] = elements
|
||||||
|
storage.content[.mainContent] = newContentStorage
|
||||||
|
|
||||||
|
for (index, element) in elements.enumerated() {
|
||||||
|
content(element).updateStorage(
|
||||||
|
newContentStorage[index],
|
||||||
|
data: data,
|
||||||
|
updateProperties: updateProperties,
|
||||||
|
type: type
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for function in updateFunctions {
|
||||||
|
function(storage, data, updateProperties)
|
||||||
|
}
|
||||||
|
if updateProperties {
|
||||||
|
storage.previousState = self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Modifier Methods
|
||||||
|
|
||||||
|
extension WrapBox {
|
||||||
|
|
||||||
|
/// The amount of space between children.
|
||||||
|
public func childSpacing(_ childSpacing: Int?) -> Self {
|
||||||
|
modify { $0.childSpacing = childSpacing }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unit for `childSpacing`.
|
||||||
|
public func childSpacingUnit(_ childSpacingUnit: LengthUnit?) -> Self {
|
||||||
|
modify { $0.childSpacingUnit = childSpacingUnit }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The packing direction.
|
||||||
|
public func packDirection(_ packDirection: PackDirection?) -> Self {
|
||||||
|
modify { $0.packDirection = packDirection }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The alignment of children within each line (0.0 to 1.0).
|
||||||
|
public func align(_ align: Float?) -> Self {
|
||||||
|
modify { $0.align = align }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The justification mode.
|
||||||
|
public func justify(_ justify: JustifyMode?) -> Self {
|
||||||
|
modify { $0.justify = justify }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether to justify the last line.
|
||||||
|
public func justifyLastLine(_ justifyLastLine: Bool? = true) -> Self {
|
||||||
|
modify { $0.justifyLastLine = justifyLastLine }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The amount of space between lines.
|
||||||
|
public func lineSpacing(_ lineSpacing: Int?) -> Self {
|
||||||
|
modify { $0.lineSpacing = lineSpacing }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unit for `lineSpacing`.
|
||||||
|
public func lineSpacingUnit(_ lineSpacingUnit: LengthUnit?) -> Self {
|
||||||
|
modify { $0.lineSpacingUnit = lineSpacingUnit }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether all lines should be the same size.
|
||||||
|
public func lineHomogeneous(_ lineHomogeneous: Bool? = true) -> Self {
|
||||||
|
modify { $0.lineHomogeneous = lineHomogeneous }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The natural length of each line.
|
||||||
|
public func naturalLineLength(_ naturalLineLength: Int?) -> Self {
|
||||||
|
modify { $0.naturalLineLength = naturalLineLength }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unit for `naturalLineLength`.
|
||||||
|
public func naturalLineLengthUnit(_ naturalLineLengthUnit: LengthUnit?) -> Self {
|
||||||
|
modify { $0.naturalLineLengthUnit = naturalLineLengthUnit }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether to reverse the wrapping direction.
|
||||||
|
public func wrapReverse(_ wrapReverse: Bool? = true) -> Self {
|
||||||
|
modify { $0.wrapReverse = wrapReverse }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wrapping policy.
|
||||||
|
public func wrapPolicy(_ wrapPolicy: WrapPolicy?) -> Self {
|
||||||
|
modify { $0.wrapPolicy = wrapPolicy }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Convenience Initializer for Identifiable Elements
|
||||||
|
|
||||||
|
extension WrapBox where Element: Identifiable, Identifier == Element.ID {
|
||||||
|
|
||||||
|
/// Initialize `WrapBox` with identifiable elements.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - elements: The identifiable elements to display.
|
||||||
|
/// - content: A view builder for rendering each element.
|
||||||
|
public init(
|
||||||
|
_ elements: [Element],
|
||||||
|
@ViewBuilder content: @escaping (Element) -> Body
|
||||||
|
) {
|
||||||
|
self.elements = elements
|
||||||
|
self.content = content
|
||||||
|
self.id = \.id
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
29
Sources/LuminateUI/Utilities/Bin+Extensions.swift
Normal file
29
Sources/LuminateUI/Utilities/Bin+Extensions.swift
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
//
|
||||||
|
// AnyView+Overflow.swift
|
||||||
|
//
|
||||||
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//
|
||||||
|
|
||||||
|
import Adwaita
|
||||||
|
|
||||||
|
extension Bin {
|
||||||
|
public init(@ViewBuilder content: @escaping () -> Body) {
|
||||||
|
self.init()
|
||||||
|
self = self.child(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
Sources/LuminateUI/Utilities/Button+Content.swift
Normal file
32
Sources/LuminateUI/Utilities/Button+Content.swift
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
//
|
||||||
|
// HomePosterCell.swift
|
||||||
|
//
|
||||||
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
//
|
||||||
|
|
||||||
|
import Adwaita
|
||||||
|
|
||||||
|
extension Button {
|
||||||
|
|
||||||
|
public init(@ViewBuilder child: @escaping () -> Body, handler: @escaping () -> Void) {
|
||||||
|
self.init("", handler: handler)
|
||||||
|
self = self.label(nil)
|
||||||
|
.child(child)
|
||||||
|
.hasFrame(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//
|
//
|
||||||
// PageAnimationTracker.swift
|
// ViewUpdateScheduler.swift
|
||||||
//
|
//
|
||||||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||||||
//
|
//
|
||||||
|
|
@ -22,22 +22,23 @@
|
||||||
import Adwaita
|
import Adwaita
|
||||||
import Foundation
|
import Foundation
|
||||||
import LuminateCore
|
import LuminateCore
|
||||||
|
import Synchronization
|
||||||
|
|
||||||
public class PageAnimationTracker: PageAnimationTracking {
|
public class ViewUpdateScheduler: ViewUpdateScheduling {
|
||||||
public var isAnimating = false
|
private let isPending = Mutex<Bool>(false)
|
||||||
private var pushGeneration = 0
|
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public func markPush() {
|
public func scheduleFlush() {
|
||||||
isAnimating = true
|
let shouldSchedule = isPending.withLock {
|
||||||
pushGeneration += 1
|
if $0 { return false }
|
||||||
let captured = pushGeneration
|
$0 = true
|
||||||
Idle(delay: 250) { [weak self] in
|
return true
|
||||||
guard let self, self.pushGeneration == captured else { return false }
|
}
|
||||||
self.isAnimating = false
|
guard shouldSchedule else { return }
|
||||||
|
Idle { [weak self] in
|
||||||
StateManager.updateViews()
|
StateManager.updateViews()
|
||||||
return false
|
self?.isPending.withLock { $0 = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
187
Tests/LuminateTests/WidgetTests.swift
Normal file
187
Tests/LuminateTests/WidgetTests.swift
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import Adwaita
|
||||||
|
import CAdw
|
||||||
|
import CGtkWidgets
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Initialize GTK once per test suite.
|
||||||
|
func ensureGTKInit() {
|
||||||
|
struct Once {
|
||||||
|
static let initialized: Bool = {
|
||||||
|
gtk_init()
|
||||||
|
return true
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
_ = Once.initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a simple test widget that reports a fixed size.
|
||||||
|
func makeFixedChild(width: Int, height: Int) -> UnsafeMutablePointer<GtkWidget>? {
|
||||||
|
let box = gtk_box_new(.GTK_ORIENTATION_VERTICAL, 0)
|
||||||
|
gtk_widget_set_size_request(box, Int32(width), Int32(height))
|
||||||
|
gtk_widget_realize(box)
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measure a widget and return (minimum, natural).
|
||||||
|
func measureWidget(
|
||||||
|
_ widget: UnsafeMutablePointer<GtkWidget>?,
|
||||||
|
orientation: GtkOrientation,
|
||||||
|
forSize: Int = -1
|
||||||
|
) -> (minimum: Int, natural: Int) {
|
||||||
|
var min: Int32 = 0
|
||||||
|
var nat: Int32 = 0
|
||||||
|
gtk_widget_measure(widget, orientation, Int32(forSize), &min, &nat, nil, nil)
|
||||||
|
return (Int(min), Int(nat))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AspectContainer Tests
|
||||||
|
|
||||||
|
@Suite("Widget Tests", .serialized)
|
||||||
|
struct WidgetTests {
|
||||||
|
|
||||||
|
init() {
|
||||||
|
ensureGTKInit()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Reports height = width × ratio when for_size is known")
|
||||||
|
func measureWithKnownWidth() {
|
||||||
|
let container = aspect_container_new(2.0)!
|
||||||
|
let child = makeFixedChild(width: 100, height: 50)
|
||||||
|
gtk_widget_set_parent(child, container)
|
||||||
|
|
||||||
|
let (minH, natH) = measureWidget(
|
||||||
|
container, orientation: .GTK_ORIENTATION_VERTICAL, forSize: 100)
|
||||||
|
#expect(minH == 200, "height = 100 × 2.0 = 200")
|
||||||
|
#expect(natH == 200, "natural height = 200")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Reports height = child_min_w × ratio when for_size is unknown")
|
||||||
|
func measureFallback() {
|
||||||
|
let container = aspect_container_new(1.5)!
|
||||||
|
let child = makeFixedChild(width: 100, height: 100)
|
||||||
|
gtk_widget_set_parent(child, container)
|
||||||
|
gtk_widget_set_size_request(container, 100, -1)
|
||||||
|
|
||||||
|
let (minH, natH) = measureWidget(
|
||||||
|
container, orientation: .GTK_ORIENTATION_VERTICAL, forSize: -1)
|
||||||
|
#expect(minH == 150, "height = 100 × 1.5 = 150 (minimum)")
|
||||||
|
#expect(natH == 150, "natural = 150")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("max_width caps reported width")
|
||||||
|
func maxWidthCapsWidth() {
|
||||||
|
let container = aspect_container_new(1.5)!
|
||||||
|
let child = makeFixedChild(width: 300, height: 300)
|
||||||
|
gtk_widget_set_parent(child, container)
|
||||||
|
aspect_container_set_max_width(container.opaque(), 200)
|
||||||
|
|
||||||
|
let (minW, natW) = measureWidget(
|
||||||
|
container, orientation: .GTK_ORIENTATION_HORIZONTAL, forSize: -1)
|
||||||
|
#expect(minW == 200, "capped at 200")
|
||||||
|
#expect(natW == 200, "capped at 200")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("max_width affects height calculation")
|
||||||
|
func maxWidthAffectsHeight() {
|
||||||
|
let container = aspect_container_new(2.0)!
|
||||||
|
let child = makeFixedChild(width: 300, height: 150)
|
||||||
|
gtk_widget_set_parent(child, container)
|
||||||
|
aspect_container_set_max_width(container.opaque(), 100)
|
||||||
|
|
||||||
|
let (minH, natH) = measureWidget(
|
||||||
|
container, orientation: .GTK_ORIENTATION_VERTICAL, forSize: -1)
|
||||||
|
#expect(minH == 200, "height = max_width × ratio = 200")
|
||||||
|
#expect(natH == 200, "natural = 200")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Changing aspect ratio triggers resize")
|
||||||
|
func changeAspectRatio() {
|
||||||
|
let container = aspect_container_new(1.0)!
|
||||||
|
let child = makeFixedChild(width: 100, height: 100)
|
||||||
|
gtk_widget_set_parent(child, container)
|
||||||
|
aspect_container_set_aspect_ratio(container.opaque(), 0.5)
|
||||||
|
|
||||||
|
let (minH, natH) = measureWidget(
|
||||||
|
container, orientation: .GTK_ORIENTATION_VERTICAL, forSize: 100)
|
||||||
|
#expect(minH == 50, "height = 100 × 0.5 = 50")
|
||||||
|
#expect(natH == 50, "natural = 50")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Horizontal measure returns 0 without max_width")
|
||||||
|
func horizontalMeasureNoMaxWidth() {
|
||||||
|
let container = aspect_container_new(1.5)!
|
||||||
|
let child = makeFixedChild(width: 300, height: 200)
|
||||||
|
gtk_widget_set_parent(child, container)
|
||||||
|
|
||||||
|
let (minW, natW) = measureWidget(
|
||||||
|
container, orientation: .GTK_ORIENTATION_HORIZONTAL, forSize: -1)
|
||||||
|
#expect(minW == 0, "no max_width → reports 0")
|
||||||
|
#expect(natW == 0, "no max_width → reports 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AspectContainer Tests
|
||||||
|
|
||||||
|
/// Helper: create a FlowGrid with `n` children, each having a given size.
|
||||||
|
func makeFlowGrid(
|
||||||
|
minimumSize: Int = 100,
|
||||||
|
columnSpacing: Int = 0,
|
||||||
|
rowSpacing: Int = 0,
|
||||||
|
childSize: Int = 100,
|
||||||
|
count: Int = 4
|
||||||
|
) -> UnsafeMutablePointer<GtkWidget>? {
|
||||||
|
let grid = flow_grid_new(Int32(minimumSize), Int32(columnSpacing), Int32(rowSpacing))
|
||||||
|
for _ in 0..<count {
|
||||||
|
let child = gtk_box_new(.GTK_ORIENTATION_VERTICAL, 0)
|
||||||
|
gtk_widget_set_size_request(child, Int32(childSize), Int32(childSize))
|
||||||
|
gtk_widget_set_parent(child, grid)
|
||||||
|
}
|
||||||
|
return grid
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Height = tallest child height when for_size is known")
|
||||||
|
func measureTalliesRowHeights() {
|
||||||
|
let grid = makeFlowGrid(
|
||||||
|
minimumSize: 150, columnSpacing: 0, rowSpacing: 0, childSize: 100, count: 6)
|
||||||
|
|
||||||
|
let (minH, natH) = measureWidget(grid, orientation: .GTK_ORIENTATION_VERTICAL, forSize: 300)
|
||||||
|
#expect(minH == 300, "3 rows × 100 = 300")
|
||||||
|
#expect(natH == 300, "natural = 300")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Column spacing is accounted for in column count")
|
||||||
|
func columnSpacingAffectsCount() {
|
||||||
|
let noSpacing = makeFlowGrid(
|
||||||
|
minimumSize: 100, columnSpacing: 0, rowSpacing: 0, childSize: 100, count: 4)
|
||||||
|
let withSpacing = makeFlowGrid(
|
||||||
|
minimumSize: 100, columnSpacing: 50, rowSpacing: 0, childSize: 100, count: 4)
|
||||||
|
|
||||||
|
let (h1, _) = measureWidget(noSpacing, orientation: .GTK_ORIENTATION_VERTICAL, forSize: 250)
|
||||||
|
#expect(h1 == 200, "250 width → 2 cols × 2 rows × 100 = 200")
|
||||||
|
|
||||||
|
let (h2, _) = measureWidget(
|
||||||
|
withSpacing, orientation: .GTK_ORIENTATION_VERTICAL, forSize: 250)
|
||||||
|
#expect(h2 == 200, "250 width → 2 cols × 2 rows × 100 = 200")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Row spacing adds to total height")
|
||||||
|
func rowSpacingAddsHeight() {
|
||||||
|
let grid = makeFlowGrid(
|
||||||
|
minimumSize: 100, columnSpacing: 0, rowSpacing: 10, childSize: 100, count: 6)
|
||||||
|
|
||||||
|
let (minH, natH) = measureWidget(grid, orientation: .GTK_ORIENTATION_VERTICAL, forSize: 300)
|
||||||
|
#expect(minH == 210, "2 rows × 100 + 1 spacing × 10 = 210")
|
||||||
|
#expect(natH == 210, "natural = 210")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Single child fills one cell")
|
||||||
|
func singleChild() {
|
||||||
|
let grid = makeFlowGrid(minimumSize: 200, childSize: 150, count: 1)
|
||||||
|
|
||||||
|
let (minH, natH) = measureWidget(grid, orientation: .GTK_ORIENTATION_VERTICAL, forSize: 400)
|
||||||
|
#expect(minH == 150, "single row = child height")
|
||||||
|
#expect(natH == 150, "natural = 150")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue