diff --git a/apphelper/CHANGELOG.md b/apphelper/CHANGELOG.md
index a5302b5..502889b 100644
--- a/apphelper/CHANGELOG.md
+++ b/apphelper/CHANGELOG.md
@@ -1,5 +1,21 @@
# @churchapps/apphelper
+## 0.15.0
+
+### Minor Changes
+
+- 39e47d3: Image craft + responsive image performance for the website builder.
+
+ apphelper:
+
+ - New pluggable image-optimizer seam (`setImageOptimizer` / `responsiveImgProps` / `optimizedBackgroundImage`). Default is identity, so non-Next hosts (the Vite editor) render plain `
` unchanged; B1App registers a Next.js `getImageProps`-backed optimizer to emit `srcset`/`sizes` and WebP/AVIF backgrounds.
+ - Content images (`image`, `card`, `textWithPhoto`, `logo`) now route their `src` through the seam; `logo` also gains the missing `loading="lazy" decoding="async"`.
+ - `BoxElement` image backgrounds get optimized loading, a `focalPoint` (`background-position`), and an opt-in tint overlay (`.boxBG:before`, driven by `--overlay-color` / `--overlay-opacity`, default off).
+
+ helpers:
+
+ - Documented the new `box` answer keys (`focalPoint`, `overlayColor`, `backgroundOpacity`) in `ElementTypes`.
+
## 0.14.0
### Minor Changes
diff --git a/apphelper/package.json b/apphelper/package.json
index 8087802..acac5e2 100644
--- a/apphelper/package.json
+++ b/apphelper/package.json
@@ -1,6 +1,6 @@
{
"name": "@churchapps/apphelper",
- "version": "0.14.0",
+ "version": "0.15.0",
"description": "Library of helper functions, components, and feature modules (donations/forms/login/markdown/website) for React and NextJS ChurchApps",
"type": "module",
"main": "dist/index.js",
@@ -138,7 +138,7 @@
"slug": "^11.0.0"
},
"devDependencies": {
- "@churchapps/helpers": "^1.7.1",
+ "@churchapps/helpers": "^1.8.1",
"@eslint/js": "^10.0.1",
"@stripe/react-stripe-js": "^3.10.0",
"@stripe/stripe-js": "^7.9.0",
diff --git a/apphelper/src/website/components/elementTypes/BoxElement.tsx b/apphelper/src/website/components/elementTypes/BoxElement.tsx
index 42b2d7b..e85b8cf 100644
--- a/apphelper/src/website/components/elementTypes/BoxElement.tsx
+++ b/apphelper/src/website/components/elementTypes/BoxElement.tsx
@@ -1,5 +1,5 @@
import React, { CSSProperties } from "react";
-import { ElementInterface, SectionInterface } from "../../helpers";
+import { ElementInterface, SectionInterface, optimizedBackgroundImage } from "../../helpers";
import { DroppableArea } from "../admin/DroppableArea";
import { Element } from "../Element";
import { ApiHelper } from "../../..";
@@ -37,12 +37,17 @@ export function BoxElement(props: Props) {
};
+ const isImageBg = props.element.answers?.background?.indexOf("/") > -1;
+
const getStyle = () => {
- let result: CSSProperties = { };
- if (props.element.answers?.background?.indexOf("/") > -1) {
- result = { backgroundImage: "url('" + props.element.answers?.background + "')" };
- } else {
- result = { background: props.element.answers?.background };
+ const result: CSSProperties = isImageBg
+ ? { backgroundImage: optimizedBackgroundImage(props.element.answers?.background) }
+ : { background: props.element.answers?.background };
+ if (isImageBg) {
+ if (props.element.answers?.focalPoint) result.backgroundPosition = props.element.answers.focalPoint;
+ // overlay defaults off (opacity 0) so existing image boxes don't darken
+ (result as any)["--overlay-color"] = props.element.answers?.overlayColor || "#000";
+ (result as any)["--overlay-opacity"] = props.element.answers?.backgroundOpacity || "0";
}
if (props.element.answers?.textColor?.startsWith("var(")) result.color = props.element.answers?.textColor;
result.padding = 15;
@@ -54,6 +59,7 @@ export function BoxElement(props: Props) {
const getClass = () => {
let result = "elBox";
+ if (isImageBg) result += " boxBG";
let hc = props.element.answers?.headingColor;
if (hc) {
hc = hc.replace("var(--", "").replace(")", "");
diff --git a/apphelper/src/website/components/elementTypes/CardElement.tsx b/apphelper/src/website/components/elementTypes/CardElement.tsx
index dd60c52..90e3782 100644
--- a/apphelper/src/website/components/elementTypes/CardElement.tsx
+++ b/apphelper/src/website/components/elementTypes/CardElement.tsx
@@ -1,8 +1,10 @@
import React from "react";
-import { ElementInterface, SectionInterface } from "../../helpers";
+import { ElementInterface, SectionInterface, responsiveImgProps } from "../../helpers";
import { HtmlPreview } from "./HtmlPreview";
import { Card, CardContent } from "@mui/material";
+const IMG_SIZES = "(max-width:768px) 100vw, 33vw";
+
interface Props { element: ElementInterface; onEdit?: (section: SectionInterface | null, element: ElementInterface) => void; }
@@ -20,7 +22,7 @@ export const CardElement: React.FC = (props) => {
let photoContent = <>>;
if (props.element.answers?.photo) {
- const photo =
;
+ const photo =
;
if (props.element.answers?.url) photoContent = ({photo});
else photoContent = (photo);
}
diff --git a/apphelper/src/website/components/elementTypes/ImageElement.tsx b/apphelper/src/website/components/elementTypes/ImageElement.tsx
index e0396fa..e7ebb0c 100644
--- a/apphelper/src/website/components/elementTypes/ImageElement.tsx
+++ b/apphelper/src/website/components/elementTypes/ImageElement.tsx
@@ -1,6 +1,8 @@
import React, { CSSProperties, useCallback, useEffect, useState } from "react";
import ReactDOM from "react-dom";
-import { ElementInterface, SectionInterface } from "../../helpers";
+import { ElementInterface, SectionInterface, responsiveImgProps } from "../../helpers";
+
+const IMG_SIZES = "(max-width:768px) 100vw, 66vw";
interface Props {
element: ElementInterface;
@@ -63,7 +65,7 @@ export const ImageElement = ({ element }: Props) => {
if (imageUrl) {
const imgTag = (
= (props) => {
const photo = (
);
const photoContent = (props.element.answers?.url) ? {photo} : photo;
diff --git a/apphelper/src/website/components/elementTypes/TextWithPhoto.tsx b/apphelper/src/website/components/elementTypes/TextWithPhoto.tsx
index 44d0ed6..f9a2c21 100644
--- a/apphelper/src/website/components/elementTypes/TextWithPhoto.tsx
+++ b/apphelper/src/website/components/elementTypes/TextWithPhoto.tsx
@@ -1,7 +1,9 @@
-import { ElementInterface, SectionInterface } from "../../helpers";
+import { ElementInterface, SectionInterface, responsiveImgProps } from "../../helpers";
import { Grid } from "@mui/material";
import { HtmlPreview } from "./HtmlPreview";
+const IMG_SIZES = "(max-width:768px) 100vw, 33vw";
+
interface Props { element: ElementInterface; onEdit?: (section: SectionInterface | null, element: ElementInterface) => void; }
export const TextWithPhoto: React.FC = props => {
@@ -21,7 +23,7 @@ export const TextWithPhoto: React.FC = props => {
result = (
-
+
{textComponent}
@@ -36,7 +38,7 @@ export const TextWithPhoto: React.FC = props => {
{textComponent}
-
+
);
@@ -45,14 +47,14 @@ export const TextWithPhoto: React.FC = props => {
result = (
<>
{textComponent}
-
+
>
);
break;
case "top":
result = (
<>
-
+
{textComponent}
>
);
diff --git a/apphelper/src/website/helpers/imageOptimizer.ts b/apphelper/src/website/helpers/imageOptimizer.ts
new file mode 100644
index 0000000..e978210
--- /dev/null
+++ b/apphelper/src/website/helpers/imageOptimizer.ts
@@ -0,0 +1,33 @@
+// Pluggable image-optimization seam. Host apps (B1App / Next.js) register an optimizer that
+// produces responsive
props and optimized CSS backgrounds via the Next image pipeline.
+// The default is identity, so the Vite editor and any non-Next host render plain images unchanged.
+
+export interface ResponsiveImgProps {
+ src: string;
+ srcSet?: string;
+ sizes?: string;
+}
+
+export interface ImageOptimizer {
+ // Responsive props to spread onto an
.
+ img: (url: string, sizes?: string) => ResponsiveImgProps;
+ // A CSS background-image value: url('…') or image-set(…).
+ background: (url: string) => string;
+}
+
+const identity: ImageOptimizer = {
+ img: (url) => ({ src: url }),
+ background: (url) => `url('${url}')`
+};
+
+let current: ImageOptimizer = identity;
+
+export const setImageOptimizer = (optimizer: ImageOptimizer | null) => {
+ current = optimizer || identity;
+};
+
+export const responsiveImgProps = (url: string, sizes?: string): ResponsiveImgProps =>
+ url ? current.img(url, sizes) : { src: url };
+
+export const optimizedBackgroundImage = (url: string): string =>
+ url ? current.background(url) : `url('${url}')`;
diff --git a/apphelper/src/website/helpers/index.ts b/apphelper/src/website/helpers/index.ts
index 08c387d..dce2346 100644
--- a/apphelper/src/website/helpers/index.ts
+++ b/apphelper/src/website/helpers/index.ts
@@ -32,6 +32,7 @@ export interface SectionInterface {
}
export * from "./StyleHelper";
+export * from "./imageOptimizer";
export * from "./EnvironmentHelper";
export * from "./interfaces";
export * from "./StreamingServiceHelper";
diff --git a/apphelper/src/website/styles/pages.css b/apphelper/src/website/styles/pages.css
index 2faddb8..a3a3a69 100644
--- a/apphelper/src/website/styles/pages.css
+++ b/apphelper/src/website/styles/pages.css
@@ -156,6 +156,28 @@ body {
box-sizing: inherit;
}
+/* Box background overlay/tint — opt-in via inline --overlay-opacity (defaults off) */
+.page .boxBG {
+ background-size: cover;
+ background-position: 50% 50%;
+ position: relative;
+}
+
+.page .boxBG:before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: var(--overlay-color, #000);
+ opacity: var(--overlay-opacity, 0);
+ pointer-events: none;
+ box-sizing: inherit;
+}
+
+.page .boxBG > * {
+ position: relative;
+ z-index: 1;
+}
+
/*
.section {
color: #333;
diff --git a/content-providers/CHANGELOG.md b/content-providers/CHANGELOG.md
index 8ddb74c..5573fa1 100644
--- a/content-providers/CHANGELOG.md
+++ b/content-providers/CHANGELOG.md
@@ -1,5 +1,34 @@
# @churchapps/content-providers
+## 0.4.0
+
+### Minor Changes
+
+- ddd001f: Slim down the content-providers surface to what the apps actually use.
+
+ Breaking:
+
+ - Removed `FormatResolver` and the `FormatConverters` namespace (plus the `ResolvedFormatMeta` and `FormatResolverOptions` types). No shipping consumer used them — apps call `provider.getPlaylist()` / `provider.getInstructions()` directly. The cross-format derivation that `FormatResolver` provided lives on only in the dev playground.
+ - Removed the unused `presentations` field from `ProviderCapabilities` and retired the never-implemented "presentations" format (dead `getPresentations` stubs, `convertFilesToPresentations`). Capabilities are now `browse` / `playlist` / `instructions` / `mediaLicensing`.
+
+ Added:
+
+ - Export `LifeChurchProvider` from the package root (it was registered but not exported).
+
+ Internal (no API change):
+
+ - `instructionsToPlaylist` moved into `utils` (it's a general converter, used by B1Church and the playground).
+ - B1Church device-flow now delegates to the shared `DeviceFlowHelper` instead of a duplicate copy.
+ - Provider registration is a single list instead of constructing and registering each provider twice.
+
+- 39e47d3: Trim the content-providers utility surface to what consumers actually use.
+
+ Breaking:
+
+ - Removed the duration-estimation module (`estimateDuration`, `estimateImageDuration`, `estimateTextDuration`, `countWords`, `DEFAULT_DURATION_CONFIG`, `DurationEstimationConfig`). The never-used text-estimation code is gone; image duration is now a single internal `IMAGE_DURATION_SECONDS = 15` constant.
+ - Removed the path helpers `buildPath`, `appendToPath`, and `generatePath` — no consumer or internal caller used them.
+ - Stopped exporting internal-only utilities from the package root: `detectMediaType`, `isMediaFile`, `createFolder`, `createFile`, `parsePath`, `getSegment`, and the `OAuthHelper` / `DeviceFlowHelper` / `ApiHelper` classes. They're still used internally — just no longer part of the public API. `TokenHelper` and `navigateToPath` remain exported.
+
## 0.3.1
### Patch Changes
diff --git a/content-providers/eslint.config.js b/content-providers/eslint.config.js
index eaf00f4..f22e75c 100644
--- a/content-providers/eslint.config.js
+++ b/content-providers/eslint.config.js
@@ -7,7 +7,7 @@ import { createRequire } from 'module';
const require = createRequire(import.meta.url);
export default tseslint.config([
- { ignores: ["node_modules/", "dist/", "build/", ".next/", "coverage/", "*.config.js"] },
+ { ignores: ["node_modules/", "dist/", "build/", ".next/", "coverage/", "*.config.js", "playground/", "cli/"] },
{
files: ["**/*.{ts,tsx,js,jsx}"],
extends: [js.configs.recommended, tseslint.configs.recommended],
diff --git a/content-providers/package.json b/content-providers/package.json
index ab614e6..0d7b827 100644
--- a/content-providers/package.json
+++ b/content-providers/package.json
@@ -1,6 +1,6 @@
{
"name": "@churchapps/content-providers",
- "version": "0.3.1",
+ "version": "0.4.0",
"type": "module",
"description": "Helper classes for interacting with third party providers",
"main": "./dist/index.js",
@@ -19,6 +19,7 @@
"build": "tsup",
"dev": "vite",
"cli": "yarn exec tsx cli/playground.ts",
+ "test": "tsx --test tests/*.test.ts",
"prepublishOnly": "yarn build",
"lint": "eslint --fix src/",
"lint:check": "eslint src/"
diff --git a/content-providers/playground/api.ts b/content-providers/playground/api.ts
index 3255cbe..5e6b334 100644
--- a/content-providers/playground/api.ts
+++ b/content-providers/playground/api.ts
@@ -1,7 +1,7 @@
import { state } from './state';
import { showLoading, showStatus } from './ui';
-import { FormatResolver, ContentItem, ContentFile, Plan, Instructions, ContentFolder } from '../src';
-import type { ResolvedFormatMeta } from '../src';
+import { ContentItem, ContentFile, Instructions, ContentFolder } from '../src';
+import { getPlaylistWithMeta, getInstructionsWithMeta, type ResolvedFormatMeta } from './formats';
/**
* Result type for viewAsPlaylist function
@@ -59,8 +59,7 @@ export async function viewAsPlaylist(folder: ContentFolder): Promise {
+ if (provider.capabilities.playlist && provider.getPlaylist) {
+ const result = await provider.getPlaylist(path, auth);
+ if (result && result.length > 0) return { data: result, meta: { isNative: true, isLossy: false } };
+ }
+ // Fall back to deriving a playlist from instructions (e.g. providers that only expose playlists
+ // at leaf depth, like Jesus Film and High Voltage Kids at the collection level).
+ if (provider.capabilities.instructions && provider.getInstructions) {
+ const instructions = await provider.getInstructions(path, auth);
+ if (instructions) return { data: instructionsToPlaylist(instructions), meta: { isNative: false, sourceFormat: 'instructions', isLossy: false } };
+ }
+ return { data: null, meta: { isNative: false, isLossy: false } };
+}
+
+export async function getInstructionsWithMeta(provider: IProvider, path: string, auth?: ContentProviderAuthData | null): Promise<{ data: Instructions | null; meta: ResolvedFormatMeta }> {
+ if (provider.capabilities.instructions && provider.getInstructions) {
+ const result = await provider.getInstructions(path, auth);
+ if (result) return { data: result, meta: { isNative: true, isLossy: false } };
+ }
+ return { data: null, meta: { isNative: false, isLossy: false } };
+}
diff --git a/content-providers/playground/views/common.ts b/content-providers/playground/views/common.ts
index 2805553..c307822 100644
--- a/content-providers/playground/views/common.ts
+++ b/content-providers/playground/views/common.ts
@@ -2,7 +2,7 @@ import { state, elements } from '../state';
import { escapeHtml, renderJsonViewer } from '../utils';
import { showStatus, showModal } from '../ui';
import { getAvailableProviders, ContentItem, ContentFolder, ContentFile, isContentFolder, isContentFile } from '../../src';
-import type { ResolvedFormatMeta } from '../../src';
+import type { ResolvedFormatMeta } from '../formats';
/**
* Render provider cards in the providers grid
@@ -41,9 +41,8 @@ export function renderProviders(onProviderClick: (providerId: string) => void):
const caps = provider.capabilities;
// Determine what formats can be derived
- const canDerivePlaylist = caps.presentations || caps.instructions;
- const canDerivePresentations = caps.instructions || caps.playlist;
- const canDeriveExpanded = caps.presentations || caps.playlist;
+ const canDerivePlaylist = caps.instructions;
+ const canDeriveExpanded = caps.playlist;
// Playlist badge
if (caps.playlist) {
@@ -52,13 +51,6 @@ export function renderProviders(onProviderClick: (providerId: string) => void):
capBadges += 'Playlist*';
}
- // Presentations badge
- if (caps.presentations) {
- capBadges += 'Presentations';
- } else if (canDerivePresentations) {
- capBadges += 'Presentations*';
- }
-
// Instructions badge
if (caps.instructions) {
capBadges += 'Instructions';
diff --git a/content-providers/playground/views/instructions.ts b/content-providers/playground/views/instructions.ts
index 8846e4c..5773d37 100644
--- a/content-providers/playground/views/instructions.ts
+++ b/content-providers/playground/views/instructions.ts
@@ -2,7 +2,7 @@ import { state, elements } from '../state';
import { escapeHtml, renderJsonViewer } from '../utils';
import { showStatus } from '../ui';
import { Instructions, InstructionItem } from '../../src';
-import type { ResolvedFormatMeta } from '../../src';
+import type { ResolvedFormatMeta } from '../formats';
import { renderFormatSourceBadge } from './common';
/**
diff --git a/content-providers/playground/views/plans.ts b/content-providers/playground/views/plans.ts
index 6795249..82d41d4 100644
--- a/content-providers/playground/views/plans.ts
+++ b/content-providers/playground/views/plans.ts
@@ -2,7 +2,7 @@ import { state, elements } from '../state';
import { escapeHtml, renderJsonViewer, formatDuration } from '../utils';
import { showStatus, showModal } from '../ui';
import { Plan, PlanPresentation, ContentItem, isContentFile } from '../../src';
-import type { ResolvedFormatMeta } from '../../src';
+import type { ResolvedFormatMeta } from '../formats';
import { renderFormatSourceBadge } from './common';
import { playPlanFiles } from './playlist';
diff --git a/content-providers/playground/views/playlist.ts b/content-providers/playground/views/playlist.ts
index 517baa6..dedb7fe 100644
--- a/content-providers/playground/views/playlist.ts
+++ b/content-providers/playground/views/playlist.ts
@@ -2,7 +2,7 @@ import { state, elements } from '../state';
import { escapeHtml, renderJsonViewer } from '../utils';
import { showStatus, showModal } from '../ui';
import { ContentFile, ContentItem, isContentFile } from '../../src';
-import type { ResolvedFormatMeta } from '../../src';
+import type { ResolvedFormatMeta } from '../formats';
import { renderFormatSourceBadge } from './common';
/**
diff --git a/content-providers/src/FormatConverters.ts b/content-providers/src/FormatConverters.ts
deleted file mode 100644
index 8984572..0000000
--- a/content-providers/src/FormatConverters.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-import type { ContentFile, Plan, PlanSection, PlanPresentation, Instructions, InstructionItem } from "./interfaces";
-import { detectMediaType } from "./utils";
-
-function generateId(): string {
- return "gen-" + Math.random().toString(36).substring(2, 11);
-}
-
-function mapItemTypeToActionType(itemType?: string): "play" | "other" {
- switch (itemType) {
- case "action":
- case "lessonAction":
- case "providerPresentation":
- case "play":
- case "addon":
- case "add-on":
- case "lessonAddOn":
- case "providerFile": return "play";
- default: return "other";
- }
-}
-
-// LOSSLESS: All file data preserved, only hierarchy lost
-export function presentationsToPlaylist(plan: Plan): ContentFile[] {
- if (plan.allFiles && plan.allFiles.length > 0) {
- return [...plan.allFiles];
- }
-
- const files: ContentFile[] = [];
- for (const section of plan.sections) {
- for (const presentation of section.presentations) {
- files.push(...presentation.files);
- }
- }
- return files;
-}
-
-function mapActionTypeToItemType(actionType: "play" | "other"): string {
- switch (actionType) {
- case "play": return "action";
- default: return "item";
- }
-}
-
-// LOSSLESS: All hierarchy and file data preserved
-export function presentationsToExpandedInstructions(plan: Plan): Instructions {
- return {
- name: plan.name,
- items: plan.sections.map(section => ({
- id: section.id,
- itemType: "section",
- label: section.name,
- children: section.presentations.map(pres => ({
- id: pres.id,
- itemType: mapActionTypeToItemType(pres.actionType),
- label: pres.name,
- description: pres.actionType !== "other" ? pres.actionType : undefined,
- seconds: pres.files.reduce((sum, f) => sum + (f.seconds || 0), 0) || undefined,
- children: pres.files.map(f => ({
- id: f.id,
- itemType: "file",
- label: f.title,
- seconds: f.seconds,
- downloadUrl: f.downloadUrl || f.url,
- thumbnail: f.thumbnail
- }))
- }))
- }))
- };
-}
-
-// LOSSLESS for media: All items with downloadUrl become files
-export function instructionsToPlaylist(instructions: Instructions): ContentFile[] {
- const files: ContentFile[] = [];
-
- function extractFiles(items: InstructionItem[]) {
- for (const item of items) {
- if (item.downloadUrl && (item.itemType === "file" || !item.children?.length)) {
- files.push({ type: "file", id: item.id || item.relatedId || generateId(), title: item.label || "Untitled", mediaType: detectMediaType(item.downloadUrl), url: item.downloadUrl, downloadUrl: item.downloadUrl, seconds: item.seconds, thumbnail: item.thumbnail });
- }
- if (item.children) {
- extractFiles(item.children);
- }
- }
- }
-
- extractFiles(instructions.items);
- return files;
-}
-
-export const expandedInstructionsToPlaylist = instructionsToPlaylist;
-
-// LOSSLESS when instructions have proper structure
-export function instructionsToPresentations(instructions: Instructions, planId?: string): Plan {
- const allFiles: ContentFile[] = [];
-
- const sections: PlanSection[] = instructions.items.filter(item => item.children && item.children.length > 0).map(sectionItem => {
- const presentations: PlanPresentation[] = (sectionItem.children || []).map(presItem => {
- const files: ContentFile[] = [];
-
- if (presItem.children && presItem.children.length > 0) {
- for (const child of presItem.children) {
- if (child.downloadUrl) {
- const file: ContentFile = { type: "file", id: child.id || child.relatedId || generateId(), title: child.label || "Untitled", mediaType: detectMediaType(child.downloadUrl), url: child.downloadUrl, downloadUrl: child.downloadUrl, seconds: child.seconds, thumbnail: child.thumbnail };
- allFiles.push(file);
- files.push(file);
- }
- }
- }
-
- if (files.length === 0 && presItem.downloadUrl) {
- const file: ContentFile = { type: "file", id: presItem.id || presItem.relatedId || generateId(), title: presItem.label || "Untitled", mediaType: detectMediaType(presItem.downloadUrl), url: presItem.downloadUrl, downloadUrl: presItem.downloadUrl, seconds: presItem.seconds, thumbnail: presItem.thumbnail };
- allFiles.push(file);
- files.push(file);
- }
-
- return { id: presItem.id || presItem.relatedId || generateId(), name: presItem.label || "Presentation", actionType: mapItemTypeToActionType(presItem.itemType), files };
- });
-
- return { id: sectionItem.id || sectionItem.relatedId || generateId(), name: sectionItem.label || "Section", presentations };
- });
-
- return { id: planId || generateId(), name: instructions.name || "Plan", sections, allFiles };
-}
-
-export const expandedInstructionsToPresentations = instructionsToPresentations;
-
-// LOSSY: No structural information - all files in one section
-export function playlistToPresentations(files: ContentFile[], planName: string = "Playlist", sectionName: string = "Content"): Plan {
- const presentations: PlanPresentation[] = files.map((file, index) => ({ id: `pres-${index}-${file.id}`, name: file.title, actionType: "play" as const, files: [file] }));
- return { id: "playlist-plan-" + generateId(), name: planName, sections: [{ id: "main-section", name: sectionName, presentations }], allFiles: [...files] };
-}
-
-// LOSSY: Minimal structure - just file references in a single section
-export function playlistToInstructions(files: ContentFile[], name: string = "Playlist"): Instructions {
- return { name, items: [{ id: "main-section", itemType: "section", label: "Content", children: files.map((file, index) => ({ id: file.id || `item-${index}`, itemType: "file", label: file.title, seconds: file.seconds, downloadUrl: file.downloadUrl || file.url, thumbnail: file.thumbnail })) }] };
-}
-
-export const playlistToExpandedInstructions = playlistToInstructions;
diff --git a/content-providers/src/FormatResolver.ts b/content-providers/src/FormatResolver.ts
deleted file mode 100644
index 08dc35e..0000000
--- a/content-providers/src/FormatResolver.ts
+++ /dev/null
@@ -1,173 +0,0 @@
-import type { IProvider, ContentFile, ContentProviderAuthData, Instructions } from "./interfaces";
-import * as Converters from "./FormatConverters";
-import { parsePath } from "./pathUtils";
-
-export interface FormatResolverOptions {
- allowLossy?: boolean;
-}
-
-export interface ResolvedFormatMeta {
- isNative: boolean;
- sourceFormat?: "playlist" | "presentations" | "instructions";
- isLossy: boolean;
-}
-
-export class FormatResolver {
- private provider: IProvider;
- private options: Required;
-
- constructor(provider: IProvider, options: FormatResolverOptions = {}) {
- this.provider = provider;
- this.options = { allowLossy: options.allowLossy ?? true };
- }
-
- getProvider(): IProvider {
- return this.provider;
- }
-
- /** Extract the last segment from a path to use as fallback ID/title */
- private getIdFromPath(path: string): string {
- const { segments } = parsePath(path);
- return segments[segments.length - 1] || "content";
- }
-
- async getPlaylist(path: string, auth?: ContentProviderAuthData | null): Promise {
- const caps = this.provider.capabilities;
-
- if (caps.playlist && this.provider.getPlaylist) {
- const result = await this.provider.getPlaylist(path, auth);
- if (result && result.length > 0) return result;
- }
-
- // if (caps.presentations) {
- // const plan = await this.provider.getPresentations(path, auth);
- // if (plan) return Converters.presentationsToPlaylist(plan);
- // }
-
- if (caps.instructions && this.provider.getInstructions) {
- const expanded = await this.provider.getInstructions(path, auth);
- if (expanded) return Converters.instructionsToPlaylist(expanded);
- }
-
- return null;
- }
-
- async getPlaylistWithMeta(path: string, auth?: ContentProviderAuthData | null): Promise<{ data: ContentFile[] | null; meta: ResolvedFormatMeta }> {
- const caps = this.provider.capabilities;
-
- if (caps.playlist && this.provider.getPlaylist) {
- const result = await this.provider.getPlaylist(path, auth);
- if (result && result.length > 0) {
- return { data: result, meta: { isNative: true, isLossy: false } };
- }
- }
-
- // if (caps.presentations) {
- // const plan = await this.provider.getPresentations(path, auth);
- // if (plan) return { data: Converters.presentationsToPlaylist(plan), meta: { isNative: false, sourceFormat: "presentations", isLossy: false } };
- // }
-
- if (caps.instructions && this.provider.getInstructions) {
- const expanded = await this.provider.getInstructions(path, auth);
- if (expanded) return { data: Converters.instructionsToPlaylist(expanded), meta: { isNative: false, sourceFormat: "instructions", isLossy: false } };
- }
-
- return { data: null, meta: { isNative: false, isLossy: false } };
- }
-
- // async getPresentations(path: string, auth?: ContentProviderAuthData | null): Promise {
- // const caps = this.provider.capabilities;
- // const fallbackId = this.getIdFromPath(path);
-
- // if (caps.presentations) {
- // const result = await this.provider.getPresentations(path, auth);
- // if (result) return result;
- // }
-
- // if (caps.instructions && this.provider.getInstructions) {
- // const expanded = await this.provider.getInstructions(path, auth);
- // if (expanded) return Converters.instructionsToPresentations(expanded, fallbackId);
- // }
-
- // if (this.options.allowLossy && caps.playlist && this.provider.getPlaylist) {
- // const playlist = await this.provider.getPlaylist(path, auth);
- // if (playlist && playlist.length > 0) {
- // return Converters.playlistToPresentations(playlist, fallbackId);
- // }
- // }
-
- // return null;
- // }
-
- // async getPresentationsWithMeta(path: string, auth?: ContentProviderAuthData | null): Promise<{ data: Plan | null; meta: ResolvedFormatMeta }> {
- // const caps = this.provider.capabilities;
- // const fallbackId = this.getIdFromPath(path);
-
- // if (caps.presentations) {
- // const result = await this.provider.getPresentations(path, auth);
- // if (result) {
- // return { data: result, meta: { isNative: true, isLossy: false } };
- // }
- // }
-
- // if (caps.instructions && this.provider.getInstructions) {
- // const expanded = await this.provider.getInstructions(path, auth);
- // if (expanded) return { data: Converters.instructionsToPresentations(expanded, fallbackId), meta: { isNative: false, sourceFormat: "instructions", isLossy: false } };
- // }
-
- // if (this.options.allowLossy && caps.playlist && this.provider.getPlaylist) {
- // const playlist = await this.provider.getPlaylist(path, auth);
- // if (playlist && playlist.length > 0) return { data: Converters.playlistToPresentations(playlist, fallbackId), meta: { isNative: false, sourceFormat: "playlist", isLossy: true } };
- // }
-
- // return { data: null, meta: { isNative: false, isLossy: false } };
- // }
-
- async getInstructions(path: string, auth?: ContentProviderAuthData | null): Promise {
- const caps = this.provider.capabilities;
- const fallbackTitle = this.getIdFromPath(path);
-
- if (caps.instructions && this.provider.getInstructions) {
- const result = await this.provider.getInstructions(path, auth);
- if (result) return result;
- }
-
- // if (caps.presentations) {
- // const plan = await this.provider.getPresentations(path, auth);
- // if (plan) return Converters.presentationsToExpandedInstructions(plan);
- // }
-
- if (this.options.allowLossy && caps.playlist && this.provider.getPlaylist) {
- const playlist = await this.provider.getPlaylist(path, auth);
- if (playlist && playlist.length > 0) {
- return Converters.playlistToInstructions(playlist, fallbackTitle);
- }
- }
-
- return null;
- }
-
- async getInstructionsWithMeta(path: string, auth?: ContentProviderAuthData | null): Promise<{ data: Instructions | null; meta: ResolvedFormatMeta }> {
- const caps = this.provider.capabilities;
- const fallbackTitle = this.getIdFromPath(path);
-
- if (caps.instructions && this.provider.getInstructions) {
- const result = await this.provider.getInstructions(path, auth);
- if (result) {
- return { data: result, meta: { isNative: true, isLossy: false } };
- }
- }
-
- // if (caps.presentations) {
- // const plan = await this.provider.getPresentations(path, auth);
- // if (plan) return { data: Converters.presentationsToExpandedInstructions(plan), meta: { isNative: false, sourceFormat: "presentations", isLossy: false } };
- // }
-
- if (this.options.allowLossy && caps.playlist && this.provider.getPlaylist) {
- const playlist = await this.provider.getPlaylist(path, auth);
- if (playlist && playlist.length > 0) return { data: Converters.playlistToInstructions(playlist, fallbackTitle), meta: { isNative: false, sourceFormat: "playlist", isLossy: true } };
- }
-
- return { data: null, meta: { isNative: false, isLossy: false } };
- }
-}
diff --git a/content-providers/src/durationUtils.ts b/content-providers/src/durationUtils.ts
deleted file mode 100644
index 13dbce3..0000000
--- a/content-providers/src/durationUtils.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-export interface DurationEstimationConfig {
- secondsPerImage: number; // Default: 15
- wordsPerMinute: number; // Default: 150
-}
-
-export const DEFAULT_DURATION_CONFIG: DurationEstimationConfig = {
- secondsPerImage: 15,
- wordsPerMinute: 150
-};
-
-/**
- * Count words in text (splits on whitespace)
- */
-export function countWords(text: string): number {
- if (!text || !text.trim()) return 0;
- return text.trim().split(/\s+/).length;
-}
-
-/**
- * Estimate duration for image content
- * @returns Duration in seconds (default: 15)
- */
-export function estimateImageDuration(
- config: Partial = {}
-): number {
- return config.secondsPerImage ?? DEFAULT_DURATION_CONFIG.secondsPerImage;
-}
-
-/**
- * Estimate duration for text content based on word count
- * @param text - The text content
- * @param config - Optional configuration overrides
- * @returns Duration in seconds
- */
-export function estimateTextDuration(
- text: string,
- config: Partial = {}
-): number {
- const words = countWords(text);
- const wpm = config.wordsPerMinute ?? DEFAULT_DURATION_CONFIG.wordsPerMinute;
- return Math.ceil((words / wpm) * 60);
-}
-
-/**
- * Estimate duration based on media type
- * @param mediaType - "video" | "image" | "text"
- * @param options - Text content or word count for text estimation
- * @returns Duration in seconds (0 for video/unknown)
- */
-export function estimateDuration(
- mediaType: "video" | "image" | "text",
- options?: {
- text?: string;
- wordCount?: number;
- config?: Partial;
- }
-): number {
- const config = options?.config ?? {};
-
- switch (mediaType) {
- case "image": return estimateImageDuration(config);
- case "text":
- if (options?.wordCount) {
- const wpm = config.wordsPerMinute ?? DEFAULT_DURATION_CONFIG.wordsPerMinute;
- return Math.ceil((options.wordCount / wpm) * 60);
- }
- if (options?.text) {
- return estimateTextDuration(options.text, config);
- }
- return 0;
- case "video":
- default: return 0;
- }
-}
diff --git a/content-providers/src/index.ts b/content-providers/src/index.ts
index 25a98f2..575a718 100644
--- a/content-providers/src/index.ts
+++ b/content-providers/src/index.ts
@@ -11,26 +11,10 @@ export const VERSION = __PACKAGE_VERSION__;
export * from "./interfaces";
// Utilities
-export { detectMediaType, isMediaFile, createFolder, createFile } from "./utils";
-export { parsePath, getSegment, buildPath, appendToPath } from "./pathUtils";
-export { navigateToPath, generatePath } from "./instructionPathUtils";
-export {
- estimateDuration,
- estimateImageDuration,
- estimateTextDuration,
- countWords,
- DEFAULT_DURATION_CONFIG,
- type DurationEstimationConfig
-} from "./durationUtils";
-
-// Format conversion utilities (access via FormatConverters namespace)
-export * as FormatConverters from "./FormatConverters";
-
-// Format resolver
-export { FormatResolver, type FormatResolverOptions, type ResolvedFormatMeta } from "./FormatResolver";
+export { navigateToPath } from "./instructionPathUtils";
// Helper classes (for standalone use or custom providers)
-export { OAuthHelper, TokenHelper, DeviceFlowHelper, ApiHelper } from "./helpers";
+export { TokenHelper } from "./helpers";
// Built-in providers
export { APlayProvider } from "./providers/aPlay";
@@ -43,6 +27,7 @@ export { BibleProjectProvider } from "./providers/bibleProject";
export { HighVoltageKidsProvider } from "./providers/highVoltage";
export { JesusFilmProvider } from "./providers/jesusFilm";
export { CbnProvider } from "./providers/cbn";
+export { LifeChurchProvider } from "./providers/lifeChurch";
// Registry functions
export {
diff --git a/content-providers/src/instructionPathUtils.ts b/content-providers/src/instructionPathUtils.ts
index a4645fe..3ef91c3 100644
--- a/content-providers/src/instructionPathUtils.ts
+++ b/content-providers/src/instructionPathUtils.ts
@@ -18,11 +18,3 @@ export function navigateToPath(instructions: Instructions, path: string): Instru
return current;
}
-
-/**
- * Generate a path string for an item given its position in the tree.
- * Used when selecting an item to store its path.
- */
-export function generatePath(indices: number[]): string {
- return indices.join(".");
-}
diff --git a/content-providers/src/interfaces.ts b/content-providers/src/interfaces.ts
index 3e9d532..fe652b1 100644
--- a/content-providers/src/interfaces.ts
+++ b/content-providers/src/interfaces.ts
@@ -227,7 +227,6 @@ export interface VenueActionsResponseInterface {
export interface ProviderCapabilities {
browse: boolean;
- presentations: boolean;
playlist: boolean;
instructions: boolean;
mediaLicensing: boolean;
@@ -259,7 +258,6 @@ export interface IProvider {
// Core methods (required)
browse(path?: string | null, auth?: ContentProviderAuthData | null): Promise;
- // getPresentations(path: string, auth?: ContentProviderAuthData | null): Promise;
// Auth methods (required)
supportsDeviceFlow(): boolean;
diff --git a/content-providers/src/pathUtils.ts b/content-providers/src/pathUtils.ts
index b1123e6..eda97d5 100644
--- a/content-providers/src/pathUtils.ts
+++ b/content-providers/src/pathUtils.ts
@@ -32,27 +32,3 @@ export function getSegment(path: string | null | undefined, index: number): stri
const { segments } = parsePath(path);
return segments[index] ?? null;
}
-
-/**
- * Build a path string from segments.
- * @param segments - Array of path segments
- * @returns Path string with leading slash
- */
-export function buildPath(segments: string[]): string {
- if (segments.length === 0) return "/";
- return "/" + segments.join("/");
-}
-
-/**
- * Append a segment to an existing path.
- * @param basePath - The base path
- * @param segment - The segment to append
- * @returns New path with segment appended
- */
-export function appendToPath(basePath: string | null | undefined, segment: string): string {
- if (!basePath || basePath === "/" || basePath === "") {
- return "/" + segment;
- }
- const cleanBase = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
- return cleanBase + "/" + segment;
-}
diff --git a/content-providers/src/providers/aPlay/APlayConverters.ts b/content-providers/src/providers/aPlay/APlayConverters.ts
index 8c2e43b..4fd4d64 100644
--- a/content-providers/src/providers/aPlay/APlayConverters.ts
+++ b/content-providers/src/providers/aPlay/APlayConverters.ts
@@ -1,4 +1,4 @@
-import { ContentFile, ContentItem, PlanPresentation, InstructionItem } from "../../interfaces";
+import { ContentFile, ContentItem, InstructionItem } from "../../interfaces";
import { detectMediaType, createFolder, createFile } from "../../utils";
import { parsePath } from "../../pathUtils";
@@ -89,28 +89,6 @@ export function convertProductsToFolders(products: Record[], cu
});
}
-/**
- * Convert files to presentations
- */
-interface ConvertFilesResult {
- presentations: PlanPresentation[];
- plan: {
- id: string;
- name: string;
- sections: { id: string; name: string; presentations: PlanPresentation[] }[];
- allFiles: ContentFile[];
- };
-}
-
-export function convertFilesToPresentations(files: ContentFile[], libraryId: string): ConvertFilesResult {
- const title = "Library";
- const presentations: PlanPresentation[] = files.map(f => ({ id: f.id, name: f.title, actionType: "play" as const, files: [f] }));
- return {
- presentations,
- plan: { id: libraryId, name: title, sections: [{ id: `section-${libraryId}`, name: title, presentations }], allFiles: files }
- };
-}
-
/**
* Convert files to instructions
*/
diff --git a/content-providers/src/providers/aPlay/APlayProvider.ts b/content-providers/src/providers/aPlay/APlayProvider.ts
index ddc48ac..92e3933 100644
--- a/content-providers/src/providers/aPlay/APlayProvider.ts
+++ b/content-providers/src/providers/aPlay/APlayProvider.ts
@@ -50,7 +50,7 @@ export class APlayProvider implements IProvider {
readonly requiresAuth = true;
readonly authTypes: AuthType[] = ["oauth_pkce"];
- readonly capabilities: ProviderCapabilities = { browse: true, presentations: true, playlist: true, instructions: true, mediaLicensing: true };
+ readonly capabilities: ProviderCapabilities = { browse: true, playlist: true, instructions: true, mediaLicensing: true };
async browse(path?: string | null, auth?: ContentProviderAuthData | null): Promise {
const { segments, depth } = parsePath(path);
@@ -110,16 +110,6 @@ export class APlayProvider implements IProvider {
return convertMediaToFiles(mediaItems);
}
- // async getPresentations(path: string, auth?: ContentProviderAuthData | null): Promise {
- // const libraryId = extractLibraryId(path);
- // if (!libraryId) return null;
-
- // const files = await this.getMediaFiles(libraryId, auth) as ContentFile[];
- // if (files.length === 0) return null;
-
- // return convertFilesToPresentations(files, libraryId).plan;
- // }
-
async getPlaylist(path: string, auth?: ContentProviderAuthData | null, _resolution?: number): Promise {
const libraryId = extractLibraryId(path);
if (!libraryId) return null;
diff --git a/content-providers/src/providers/b1Church/B1ChurchAuth.ts b/content-providers/src/providers/b1Church/B1ChurchAuth.ts
index 0b5236e..c71499e 100644
--- a/content-providers/src/providers/b1Church/B1ChurchAuth.ts
+++ b/content-providers/src/providers/b1Church/B1ChurchAuth.ts
@@ -1,4 +1,4 @@
-import { ContentProviderAuthData, ContentProviderConfig, DeviceAuthorizationResponse, DeviceFlowPollResult } from "../../interfaces";
+import { ContentProviderAuthData, ContentProviderConfig } from "../../interfaces";
async function generateCodeChallenge(verifier: string): Promise {
const encoder = new TextEncoder();
@@ -79,41 +79,3 @@ export async function refreshTokenWithSecret(config: ContentProviderConfig, auth
return null;
}
}
-
-export async function initiateDeviceFlow(config: ContentProviderConfig): Promise {
- if (!config.supportsDeviceFlow || !config.deviceAuthEndpoint) return null;
-
- try {
- const response = await fetch(`${config.oauthBase}${config.deviceAuthEndpoint}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_id: config.clientId, scope: config.scopes.join(" ") }) });
-
- if (!response.ok) {
- return null;
- }
-
- return await response.json();
- } catch {
- return null;
- }
-}
-
-export async function pollDeviceFlowToken(config: ContentProviderConfig, deviceCode: string): Promise {
- try {
- const response = await fetch(`${config.oauthBase}/token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: deviceCode, client_id: config.clientId }) });
-
- if (response.ok) {
- const data = await response.json();
- return { access_token: data.access_token, refresh_token: data.refresh_token, token_type: data.token_type || "Bearer", created_at: Math.floor(Date.now() / 1000), expires_in: data.expires_in, scope: data.scope || config.scopes.join(" ") };
- }
-
- const errorData = await response.json();
- switch (errorData.error) {
- case "authorization_pending": return { error: "authorization_pending" };
- case "slow_down": return { error: "slow_down", shouldSlowDown: true };
- case "expired_token": return null;
- case "access_denied": return null;
- default: return null;
- }
- } catch {
- return { error: "network_error" };
- }
-}
diff --git a/content-providers/src/providers/b1Church/B1ChurchProvider.ts b/content-providers/src/providers/b1Church/B1ChurchProvider.ts
index aebea20..e8a42cd 100644
--- a/content-providers/src/providers/b1Church/B1ChurchProvider.ts
+++ b/content-providers/src/providers/b1Church/B1ChurchProvider.ts
@@ -1,8 +1,8 @@
import { ContentProviderConfig, ContentProviderAuthData, ContentItem, ContentFile, ProviderLogos, Plan, PlanPresentation, Instructions, ProviderCapabilities, DeviceAuthorizationResponse, DeviceFlowPollResult, IProvider, AuthType, InstructionItem, CurrentPlan } from "../../interfaces";
import { parsePath } from "../../pathUtils";
import { navigateToPath } from "../../instructionPathUtils";
-import { instructionsToPlaylist } from "../../FormatConverters";
-import { ApiHelper } from "../../helpers";
+import { ApiHelper, DeviceFlowHelper } from "../../helpers";
+import { instructionsToPlaylist } from "../../utils";
import { getProvider } from "../registry";
import { B1Plan, B1PlanItem } from "./B1ChurchTypes";
import * as B1ChurchAuth from "./B1ChurchAuth";
@@ -32,6 +32,7 @@ function isExternalProviderItem(item: B1PlanItem): boolean {
export class B1ChurchProvider implements IProvider {
private readonly apiHelper = new ApiHelper();
+ private readonly deviceFlowHelper = new DeviceFlowHelper();
// Unified cache for external provider data to avoid duplicate calls across methods
private readonly externalContentCache = {
@@ -54,7 +55,7 @@ export class B1ChurchProvider implements IProvider {
readonly requiresAuth = true;
readonly authTypes: AuthType[] = ["oauth_pkce", "device_flow"];
- readonly capabilities: ProviderCapabilities = { browse: true, presentations: true, playlist: true, instructions: true, mediaLicensing: false };
+ readonly capabilities: ProviderCapabilities = { browse: true, playlist: true, instructions: true, mediaLicensing: false };
async buildAuthUrl(codeVerifier: string, redirectUri: string, state?: string): Promise<{ url: string; challengeMethod: string }> {
return B1ChurchAuth.buildB1AuthUrl(this.config, this.appBase, redirectUri, codeVerifier, state);
@@ -73,11 +74,11 @@ export class B1ChurchProvider implements IProvider {
}
async initiateDeviceFlow(): Promise {
- return B1ChurchAuth.initiateDeviceFlow(this.config);
+ return this.deviceFlowHelper.initiateDeviceFlow(this.config);
}
async pollDeviceFlowToken(deviceCode: string): Promise {
- return B1ChurchAuth.pollDeviceFlowToken(this.config, deviceCode);
+ return this.deviceFlowHelper.pollDeviceFlowToken(this.config, deviceCode);
}
async browse(path?: string | null, authData?: ContentProviderAuthData | null): Promise {
@@ -188,125 +189,6 @@ export class B1ChurchProvider implements IProvider {
return imageMap;
}
- // async getPresentations(path: string, authData?: ContentProviderAuthData | null): Promise {
- // const { segments, depth } = parsePath(path);
-
- // if (depth < 4 || segments[0] !== "ministries") return null;
-
- // const ministryId = segments[1];
- // const planId = segments[3];
- // const planTypeId = segments[2];
-
- // // Need to fetch plan details to get churchId and contentId
- // const plans = await fetchPlans(planTypeId, authData);
- // const planFolder = plans.find(p => p.id === planId);
- // if (!planFolder) return null;
-
- // const churchId = planFolder.churchId;
- // const venueId = planFolder.contentId;
- // const planTitle = planFolder.name || "Plan";
-
- // if (!churchId) {
- // console.warn("[B1Church getPresentations] planFolder missing churchId:", planFolder.id);
- // return null;
- // }
-
- // const pathFn = this.config.endpoints.planItems as (churchId: string, planId: string) => string;
- // const planItems = await this.apiRequest(pathFn(churchId, planId), authData);
-
- // // If no planItems but plan has associated provider content, fetch from that provider
- // if ((!planItems || planItems.length === 0) && planFolder.providerId && planFolder.providerPlanId) {
- // const externalPlan = await fetchFromProviderProxy(
- // "getPresentations",
- // ministryId,
- // planFolder.providerId,
- // planFolder.providerPlanId,
- // authData
- // );
- // if (externalPlan) {
- // return { id: planId, name: planTitle, sections: externalPlan.sections, allFiles: externalPlan.allFiles };
- // }
- // }
-
- // if (!planItems || !Array.isArray(planItems)) return null;
-
- // const venueFeed = venueId ? await fetchVenueFeed(venueId) : null;
-
- // const sections: PlanSection[] = [];
- // const allFiles: ContentFile[] = [];
-
- // for (const sectionItem of planItems) {
- // const presentations: PlanPresentation[] = [];
-
- // for (const child of sectionItem.children || []) {
- // // Try external provider resolution first (cached, uses providerContentPath)
- // if (isExternalProviderItem(child) && child.providerId && child.providerPath) {
- // const cacheKey = `${child.providerId}:${child.providerPath}`;
-
- // let externalPlan = this.externalContentCache.plans.get(cacheKey);
- // if (externalPlan === undefined) {
- // externalPlan = await fetchFromProviderProxy(
- // "getPresentations",
- // ministryId,
- // child.providerId,
- // child.providerPath,
- // authData
- // );
- // this.externalContentCache.plans.set(cacheKey, externalPlan);
- // }
-
- // if (externalPlan) {
- // if (child.providerContentPath) {
- // // Fetch instructions to enable path-based lookup (with caching)
- // let externalInstructions = this.externalContentCache.instructions.get(cacheKey);
- // if (externalInstructions === undefined) {
- // externalInstructions = await fetchFromProviderProxy(
- // "getInstructions",
- // ministryId,
- // child.providerId,
- // child.providerPath,
- // authData
- // );
- // this.externalContentCache.instructions.set(cacheKey, externalInstructions);
- // }
- // // Find and use only the specific presentation
- // const matchingPresentation = this.findPresentationByPath(externalPlan, externalInstructions, child.providerContentPath);
- // if (matchingPresentation) {
- // presentations.push(matchingPresentation);
- // if (Array.isArray(matchingPresentation.files)) {
- // allFiles.push(...matchingPresentation.files);
- // }
- // }
- // } else {
- // // Add all presentations from the external plan
- // for (const section of externalPlan.sections || []) {
- // if (Array.isArray(section.presentations)) {
- // presentations.push(...section.presentations);
- // }
- // }
- // if (Array.isArray(externalPlan.allFiles)) {
- // allFiles.push(...externalPlan.allFiles);
- // }
- // }
- // }
- // } else {
- // // Handle internal items (venue feed sections, link-based files, etc.)
- // const presentation = await planItemToPresentation(child, venueFeed);
- // if (presentation) {
- // presentations.push(presentation);
- // allFiles.push(...presentation.files);
- // }
- // }
- // }
-
- // if (presentations.length > 0 || sectionItem.label) {
- // sections.push({ id: sectionItem.id, name: sectionItem.label || "Section", presentations });
- // }
- // }
-
- // return { id: planId, name: planTitle, sections, allFiles };
- // }
-
private planTypeId: string | null = null;
setPairingData(data: unknown) {
diff --git a/content-providers/src/providers/bibleProject/BibleProjectProvider.ts b/content-providers/src/providers/bibleProject/BibleProjectProvider.ts
index 30ad80f..0976a76 100644
--- a/content-providers/src/providers/bibleProject/BibleProjectProvider.ts
+++ b/content-providers/src/providers/bibleProject/BibleProjectProvider.ts
@@ -41,7 +41,6 @@ export class BibleProjectProvider implements IProvider {
readonly authTypes: AuthType[] = ["none"];
readonly capabilities: ProviderCapabilities = {
browse: true,
- presentations: true,
playlist: true,
instructions: true,
mediaLicensing: false
@@ -107,40 +106,6 @@ export class BibleProjectProvider implements IProvider {
return [createFile(video.id, video.title, video.videoUrl, { mediaType: "video", muxPlaybackId: video.muxPlaybackId, seconds: 0 })];
}
- // async getPresentations(path: string, _auth?: ContentProviderAuthData | null): Promise {
- // const { segments, depth } = parsePath(path);
-
- // if (depth < 1) return null;
-
- // const collectionSlug = segments[0];
- // const collection = this.data.collections.find(c => slugify(c.name) === collectionSlug);
- // if (!collection) return null;
-
- // // For collection level (depth 1), create a plan with all videos
- // if (depth === 1) {
- // const allFiles: ContentFile[] = [];
- // const presentations: PlanPresentation[] = collection.videos.map(video => {
- // const file: ContentFile = { type: "file", id: video.id, title: video.title, mediaType: "video", url: video.videoUrl, thumbnail: this.getMuxThumbnail(video.muxPlaybackId), muxPlaybackId: video.muxPlaybackId, seconds: 0 };
- // allFiles.push(file);
- // return { id: video.id, name: video.title, actionType: "play" as const, files: [file] };
- // });
-
- // return { id: slugify(collection.name), name: collection.name, thumbnail: collection.image || undefined, sections: [{ id: "videos", name: "Videos", presentations }], allFiles };
- // }
-
- // // For video level (depth 2, single video), create a simple plan
- // if (depth === 2) {
- // const videoId = segments[1];
- // const video = collection.videos.find(v => v.id === videoId);
- // if (!video) return null;
-
- // const file: ContentFile = { type: "file", id: video.id, title: video.title, mediaType: "video", url: video.videoUrl, thumbnail: this.getMuxThumbnail(video.muxPlaybackId), muxPlaybackId: video.muxPlaybackId, seconds: 0 };
- // return { id: video.id, name: video.title, thumbnail: this.getMuxThumbnail(video.muxPlaybackId), sections: [{ id: "main", name: "Content", presentations: [{ id: video.id, name: video.title, actionType: "play", files: [file] }] }], allFiles: [file] };
- // }
-
- // return null;
- // }
-
async getPlaylist(path: string, _auth?: ContentProviderAuthData | null, _resolution?: number): Promise {
const { segments, depth } = parsePath(path);
diff --git a/content-providers/src/providers/cbn/CbnProvider.ts b/content-providers/src/providers/cbn/CbnProvider.ts
index 3d62608..65d890e 100644
--- a/content-providers/src/providers/cbn/CbnProvider.ts
+++ b/content-providers/src/providers/cbn/CbnProvider.ts
@@ -49,7 +49,7 @@ export class CbnProvider implements IProvider {
readonly requiresAuth = true;
readonly authTypes: AuthType[] = ["device_flow"];
- readonly capabilities: ProviderCapabilities = { browse: true, presentations: false, playlist: true, instructions: true, mediaLicensing: false };
+ readonly capabilities: ProviderCapabilities = { browse: true, playlist: true, instructions: true, mediaLicensing: false };
async browse(path?: string | null, auth?: ContentProviderAuthData | null): Promise {
const { segments, depth } = parsePath(path);
diff --git a/content-providers/src/providers/dropbox/DropboxProvider.ts b/content-providers/src/providers/dropbox/DropboxProvider.ts
index ce25906..9e32c21 100644
--- a/content-providers/src/providers/dropbox/DropboxProvider.ts
+++ b/content-providers/src/providers/dropbox/DropboxProvider.ts
@@ -45,7 +45,6 @@ export class DropboxProvider implements IProvider {
readonly authTypes: AuthType[] = ["oauth_pkce"];
readonly capabilities: ProviderCapabilities = {
browse: true,
- presentations: false,
playlist: true,
instructions: true,
mediaLicensing: false
diff --git a/content-providers/src/providers/highVoltage/HighVoltageInstructions.ts b/content-providers/src/providers/highVoltage/HighVoltageInstructions.ts
index d61cad3..b852b7c 100644
--- a/content-providers/src/providers/highVoltage/HighVoltageInstructions.ts
+++ b/content-providers/src/providers/highVoltage/HighVoltageInstructions.ts
@@ -1,5 +1,5 @@
import { Instructions, InstructionItem } from "../../interfaces";
-import { estimateDuration } from "../../durationUtils";
+import { IMAGE_DURATION_SECONDS } from "../../utils";
import { LessonFileJson, LessonFolder, StudyFolder } from "./HighVoltageKidsInterfaces";
/**
@@ -22,7 +22,7 @@ export function groupFilesIntoActions(files: LessonFileJson[], thumbnail?: strin
const flushGroup = () => {
if (currentGroup.length === 0) return;
const children: InstructionItem[] = currentGroup.map(file => {
- const seconds = estimateDuration(file.mediaType as "video" | "image");
+ const seconds = file.mediaType === "image" ? IMAGE_DURATION_SECONDS : 0;
return {
id: file.id,
itemType: "file" as const,
@@ -67,7 +67,7 @@ export function groupFilesIntoActions(files: LessonFileJson[], thumbnail?: strin
export function buildStudyInstructions(study: StudyFolder): Instructions {
const lessonItems: InstructionItem[] = study.lessons.map(lesson => {
const fileItems: InstructionItem[] = lesson.files.map(file => {
- const seconds = estimateDuration(file.mediaType as "video" | "image");
+ const seconds = file.mediaType === "image" ? IMAGE_DURATION_SECONDS : 0;
return { id: file.id, itemType: "file", label: file.title, seconds, downloadUrl: file.url, thumbnail: lesson.image };
});
return { id: lesson.id, itemType: "action", label: lesson.name, actionType: "play", children: fileItems };
diff --git a/content-providers/src/providers/highVoltage/HighVoltageKidsProvider.ts b/content-providers/src/providers/highVoltage/HighVoltageKidsProvider.ts
index 7b8decd..01ab46c 100644
--- a/content-providers/src/providers/highVoltage/HighVoltageKidsProvider.ts
+++ b/content-providers/src/providers/highVoltage/HighVoltageKidsProvider.ts
@@ -39,7 +39,6 @@ export class HighVoltageKidsProvider implements IProvider {
readonly authTypes: AuthType[] = ["none"];
readonly capabilities: ProviderCapabilities = {
browse: true,
- presentations: true,
playlist: true,
instructions: true,
mediaLicensing: false
@@ -56,25 +55,6 @@ export class HighVoltageKidsProvider implements IProvider {
return [];
}
- // async getPresentations(path: string, _auth?: ContentProviderAuthData | null): Promise {
- // const { segments, depth } = parsePath(path);
-
- // if (depth < 2) return null;
-
- // const study = findStudy(this.data, segments[0], segments[1]);
- // if (!study) return null;
-
- // if (depth === 2) return buildStudyPlan(study);
-
- // if (depth === 3) {
- // const lesson = findLesson(this.data, segments[0], segments[1], segments[2]);
- // if (!lesson) return null;
- // return buildLessonPlan(lesson);
- // }
-
- // return null;
- // }
-
async getPlaylist(path: string, _auth?: ContentProviderAuthData | null, _resolution?: number): Promise {
const { segments, depth } = parsePath(path);
diff --git a/content-providers/src/providers/index.ts b/content-providers/src/providers/index.ts
index 9ae026c..e5471f9 100644
--- a/content-providers/src/providers/index.ts
+++ b/content-providers/src/providers/index.ts
@@ -1,5 +1,5 @@
-import { ProviderInfo, ProviderLogos } from "../interfaces";
-import { providerRegistry, getProvider, getAllProviders } from "./registry";
+import { IProvider, ProviderInfo, ProviderLogos } from "../interfaces";
+import { registerProvider, getProvider, getAllProviders } from "./registry";
import { APlayProvider } from "./aPlay";
import { B1ChurchProvider } from "./b1Church";
import { DropboxProvider } from "./dropbox";
@@ -78,29 +78,20 @@ const unimplementedProviders: UnimplementedProvider[] = [
// Register built-in providers
function initializeProviders() {
- const aplay = new APlayProvider();
- const b1Church = new B1ChurchProvider();
- const dropbox = new DropboxProvider();
- const bibleProject = new BibleProjectProvider();
- const highVoltageKids = new HighVoltageKidsProvider();
- const lessonsChurch = new LessonsChurchProvider();
- const lifeChurch = new LifeChurchProvider();
- const planningCenter = new PlanningCenterProvider();
- const signPresenter = new SignPresenterProvider();
- const jesusFilm = new JesusFilmProvider();
- const cbn = new CbnProvider();
-
- providerRegistry.set(aplay.id, aplay);
- providerRegistry.set(b1Church.id, b1Church);
- providerRegistry.set(dropbox.id, dropbox);
- providerRegistry.set(bibleProject.id, bibleProject);
- providerRegistry.set(highVoltageKids.id, highVoltageKids);
- providerRegistry.set(jesusFilm.id, jesusFilm);
- providerRegistry.set(lessonsChurch.id, lessonsChurch);
- providerRegistry.set(lifeChurch.id, lifeChurch);
- providerRegistry.set(planningCenter.id, planningCenter);
- providerRegistry.set(signPresenter.id, signPresenter);
- providerRegistry.set(cbn.id, cbn);
+ const providers: IProvider[] = [
+ new APlayProvider(),
+ new B1ChurchProvider(),
+ new DropboxProvider(),
+ new BibleProjectProvider(),
+ new HighVoltageKidsProvider(),
+ new JesusFilmProvider(),
+ new LessonsChurchProvider(),
+ new LifeChurchProvider(),
+ new PlanningCenterProvider(),
+ new SignPresenterProvider(),
+ new CbnProvider()
+ ];
+ for (const provider of providers) registerProvider(provider);
}
// Initialize on module load
@@ -139,7 +130,7 @@ export function getAvailableProviders(ids?: string[]): ProviderInfo[] {
implemented: false,
requiresAuth: false,
authTypes: [],
- capabilities: { browse: false, presentations: false, playlist: false, instructions: false, mediaLicensing: false }
+ capabilities: { browse: false, playlist: false, instructions: false, mediaLicensing: false }
}));
const all = [...implemented, ...comingSoon];
diff --git a/content-providers/src/providers/jesusFilm/JesusFilmProvider.ts b/content-providers/src/providers/jesusFilm/JesusFilmProvider.ts
index 015cc6f..b9fa967 100644
--- a/content-providers/src/providers/jesusFilm/JesusFilmProvider.ts
+++ b/content-providers/src/providers/jesusFilm/JesusFilmProvider.ts
@@ -47,7 +47,6 @@ export class JesusFilmProvider implements IProvider {
readonly authTypes: AuthType[] = ["none"];
readonly capabilities: ProviderCapabilities = {
browse: true,
- presentations: true,
playlist: true,
instructions: true,
mediaLicensing: false
diff --git a/content-providers/src/providers/lessonsChurch/LessonsChurchConverters.ts b/content-providers/src/providers/lessonsChurch/LessonsChurchConverters.ts
index 08c52ca..45cdd4b 100644
--- a/content-providers/src/providers/lessonsChurch/LessonsChurchConverters.ts
+++ b/content-providers/src/providers/lessonsChurch/LessonsChurchConverters.ts
@@ -1,6 +1,5 @@
import { ContentFile, FeedVenueInterface, Plan, PlanSection, PlanPresentation, InstructionItem, Instructions, VenueActionsResponseInterface } from "../../interfaces";
-import { detectMediaType } from "../../utils";
-import { estimateImageDuration } from "../../durationUtils";
+import { detectMediaType, IMAGE_DURATION_SECONDS } from "../../utils";
import { apiRequest, API_BASE } from "./LessonsChurchApi";
export function normalizeItemType(type?: string): string | undefined {
@@ -140,7 +139,7 @@ export function buildSectionActionsMap(actionsResponse: VenueActionsResponseInte
for (const section of actionsResponse.sections) {
if (section.id && section.actions) {
sectionActionsMap.set(section.id, section.actions.map(action => {
- const seconds = action.seconds ?? estimateImageDuration();
+ const seconds = action.seconds ?? IMAGE_DURATION_SECONDS;
const rawActionType = action.actionType?.toLowerCase() || "";
const hasFiles = rawActionType === "play" || rawActionType === "add-on";
const thumbnail = (action.id && actionThumbnailMap.get(action.id)) || lessonImage;
diff --git a/content-providers/src/providers/lessonsChurch/LessonsChurchProvider.ts b/content-providers/src/providers/lessonsChurch/LessonsChurchProvider.ts
index f0f8065..fb4b61a 100644
--- a/content-providers/src/providers/lessonsChurch/LessonsChurchProvider.ts
+++ b/content-providers/src/providers/lessonsChurch/LessonsChurchProvider.ts
@@ -28,7 +28,7 @@ export class LessonsChurchProvider implements IProvider {
readonly requiresAuth = false;
readonly authTypes: AuthType[] = ["none"];
- readonly capabilities: ProviderCapabilities = { browse: true, presentations: true, playlist: true, instructions: true, mediaLicensing: false };
+ readonly capabilities: ProviderCapabilities = { browse: true, playlist: true, instructions: true, mediaLicensing: false };
async getPlaylist(path: string, _auth?: ContentProviderAuthData | null, resolution?: number): Promise {
const { segments } = parsePath(path);
@@ -177,22 +177,6 @@ export class LessonsChurchProvider implements IProvider {
return filtered.map(addOn => createFolder(addOn.id as string, addOn.name as string, `${currentPath}/${addOn.id}`, addOn.image as string | undefined, true));
}
- // async getPresentations(path: string, _auth?: ContentProviderAuthData | null): Promise {
- // const venueId = getSegment(path, 4);
- // if (venueId) {
- // const venueData = await apiRequest(`/venues/public/feed/${venueId}`);
- // if (!venueData) return null;
- // return convertVenueToPlan(venueData);
- // }
-
- // const { segments } = parsePath(path);
- // if (segments[0] === "addons" && segments.length === 2) {
- // return convertAddOnCategoryToPlan(segments[1]);
- // }
-
- // return null;
- // }
-
async getInstructions(path: string, _auth?: ContentProviderAuthData | null): Promise {
const venueId = getSegment(path, 4);
if (venueId) {
diff --git a/content-providers/src/providers/lifeChurch/LifeChurchProvider.ts b/content-providers/src/providers/lifeChurch/LifeChurchProvider.ts
index 5768952..78669b5 100644
--- a/content-providers/src/providers/lifeChurch/LifeChurchProvider.ts
+++ b/content-providers/src/providers/lifeChurch/LifeChurchProvider.ts
@@ -46,7 +46,6 @@ export class LifeChurchProvider implements IProvider {
readonly authTypes: AuthType[] = ["none"];
readonly capabilities: ProviderCapabilities = {
browse: true,
- presentations: true,
playlist: true,
instructions: true,
mediaLicensing: false
diff --git a/content-providers/src/providers/planningCenter/PlanningCenterProvider.ts b/content-providers/src/providers/planningCenter/PlanningCenterProvider.ts
index 87a7fad..993a038 100644
--- a/content-providers/src/providers/planningCenter/PlanningCenterProvider.ts
+++ b/content-providers/src/providers/planningCenter/PlanningCenterProvider.ts
@@ -30,7 +30,7 @@ export class PlanningCenterProvider implements IProvider {
readonly requiresAuth = true;
readonly authTypes: AuthType[] = ["oauth_pkce"];
- readonly capabilities: ProviderCapabilities = { browse: true, presentations: true, playlist: true, instructions: true, mediaLicensing: false };
+ readonly capabilities: ProviderCapabilities = { browse: true, playlist: true, instructions: true, mediaLicensing: false };
async browse(path?: string | null, auth?: ContentProviderAuthData | null): Promise {
const { segments, depth } = parsePath(path);
@@ -102,70 +102,6 @@ export class PlanningCenterProvider implements IProvider {
return response.data.map((item) => ({ type: "file" as const, id: item.id, title: item.attributes.title || "", mediaType: "image" as const, url: "" }));
}
- // async getPresentations(path: string, auth?: ContentProviderAuthData | null): Promise {
- // const { segments, depth } = parsePath(path);
-
- // if (depth < 3 || segments[0] !== "serviceTypes") return null;
-
- // const serviceTypeId = segments[1];
- // const planId = segments[2];
-
- // const pathFn = this.config.endpoints.planItems as (stId: string, pId: string) => string;
- // const response = await this.apiRequest<{ data: PCOPlanItem[] }>(
- // `${pathFn(serviceTypeId, planId)}?per_page=100`,
- // auth
- // );
-
- // if (!response?.data) return null;
-
- // const plans = await this.getPlans(serviceTypeId, `/serviceTypes/${serviceTypeId}`, auth);
- // const plan = plans.find(p => p.id === planId);
- // const planTitle = plan?.title || "Plan";
-
- // const sections: PlanSection[] = [];
- // const allFiles: ContentFile[] = [];
- // let currentSection: PlanSection | null = null;
-
- // for (const item of response.data) {
- // const itemType = item.attributes.item_type;
-
- // if (itemType === "header") {
- // if (currentSection && currentSection.presentations.length > 0) sections.push(currentSection);
- // currentSection = { id: item.id, name: item.attributes.title || "Section", presentations: [] };
- // continue;
- // }
-
- // if (!currentSection) {
- // currentSection = { id: `default-${planId}`, name: "Service", presentations: [] };
- // }
-
- // const presentation = await convertToPresentation(this.config, item, auth);
- // if (presentation) {
- // currentSection.presentations.push(presentation);
- // allFiles.push(...presentation.files);
- // }
- // }
-
- // if (currentSection && currentSection.presentations.length > 0) {
- // sections.push(currentSection);
- // }
-
- // return { id: planId, name: planTitle as string, sections, allFiles };
- // }
-
- // async getPlaylist(path: string, auth?: ContentProviderAuthData | null, _resolution?: number): Promise {
- // const plan = await this.getPresentations(path, auth);
- // if (!plan) return null;
- // return plan.allFiles.length > 0 ? plan.allFiles : null;
- // }
-
- // async getInstructions(path: string, auth?: ContentProviderAuthData | null): Promise {
- // const plan = await this.getPresentations(path, auth);
- // if (!plan) return null;
-
- // return buildInstructionsFromPlan(plan);
- // }
-
supportsDeviceFlow(): boolean {
return false;
}
diff --git a/content-providers/src/providers/signPresenter/SignPresenterProvider.ts b/content-providers/src/providers/signPresenter/SignPresenterProvider.ts
index 00bac83..26f0d3f 100644
--- a/content-providers/src/providers/signPresenter/SignPresenterProvider.ts
+++ b/content-providers/src/providers/signPresenter/SignPresenterProvider.ts
@@ -27,7 +27,7 @@ export class SignPresenterProvider implements IProvider {
readonly requiresAuth = true;
readonly authTypes: AuthType[] = ["oauth_pkce", "device_flow"];
- readonly capabilities: ProviderCapabilities = { browse: true, presentations: true, playlist: true, instructions: true, mediaLicensing: false };
+ readonly capabilities: ProviderCapabilities = { browse: true, playlist: true, instructions: true, mediaLicensing: false };
async browse(path?: string | null, auth?: ContentProviderAuthData | null): Promise {
const { segments, depth } = parsePath(path);
@@ -116,25 +116,6 @@ export class SignPresenterProvider implements IProvider {
return files;
}
- // async getPresentations(path: string, auth?: ContentProviderAuthData | null): Promise {
- // const { segments, depth } = parsePath(path);
-
- // if (depth < 2 || segments[0] !== "playlists") return null;
-
- // const playlistId = segments[1];
- // const files = await this.getMessages(playlistId, auth) as ContentFile[];
- // if (files.length === 0) return null;
-
- // // Get playlist info for title
- // const playlists = await this.getPlaylists(auth);
- // const playlist = playlists.find(p => p.id === playlistId);
- // const title = playlist?.title || "Playlist";
- // const thumbnail = (playlist as Record | undefined)?.image as string | undefined;
-
- // const presentations: PlanPresentation[] = files.map(f => ({ id: f.id, name: f.title, actionType: "play" as const, files: [f] }));
- // return { id: playlistId, name: title as string, thumbnail, sections: [{ id: `section-${playlistId}`, name: title as string, presentations }], allFiles: files };
- // }
-
async getPlaylist(path: string, auth?: ContentProviderAuthData | null, _resolution?: number): Promise {
const { segments, depth } = parsePath(path);
diff --git a/content-providers/src/utils.ts b/content-providers/src/utils.ts
index 6e8ac9c..1175d75 100644
--- a/content-providers/src/utils.ts
+++ b/content-providers/src/utils.ts
@@ -1,4 +1,6 @@
-import { ContentFolder, ContentFile } from "./interfaces";
+import { ContentFolder, ContentFile, Instructions, InstructionItem } from "./interfaces";
+
+export const IMAGE_DURATION_SECONDS = 15;
export function slugify(text: string): string {
return text
@@ -32,3 +34,24 @@ export function createFolder(id: string, title: string, path: string, thumbnail?
export function createFile(id: string, title: string, url: string, options?: { mediaType?: "video" | "image"; thumbnail?: string; muxPlaybackId?: string; seconds?: number; loop?: boolean; loopVideo?: boolean; streamUrl?: string; }): ContentFile {
return { type: "file", id, title, url, mediaType: options?.mediaType ?? detectMediaType(url), thumbnail: options?.thumbnail, muxPlaybackId: options?.muxPlaybackId, seconds: options?.seconds, loop: options?.loop, loopVideo: options?.loopVideo, streamUrl: options?.streamUrl };
}
+
+function generateId(): string {
+ return "gen-" + Math.random().toString(36).substring(2, 11);
+}
+
+/** Flatten an Instructions tree into a playlist of downloadable files. */
+export function instructionsToPlaylist(instructions: Instructions): ContentFile[] {
+ const files: ContentFile[] = [];
+
+ function extractFiles(items: InstructionItem[]) {
+ for (const item of items) {
+ if (item.downloadUrl && (item.itemType === "file" || !item.children?.length)) {
+ files.push({ type: "file", id: item.id || item.relatedId || generateId(), title: item.label || "Untitled", mediaType: detectMediaType(item.downloadUrl), url: item.downloadUrl, downloadUrl: item.downloadUrl, seconds: item.seconds, thumbnail: item.thumbnail });
+ }
+ if (item.children) extractFiles(item.children);
+ }
+ }
+
+ extractFiles(instructions.items);
+ return files;
+}
diff --git a/content-providers/tests/contentProviders.test.ts b/content-providers/tests/contentProviders.test.ts
new file mode 100644
index 0000000..36178aa
--- /dev/null
+++ b/content-providers/tests/contentProviders.test.ts
@@ -0,0 +1,241 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+
+// Import from submodules rather than ../src/index: the index injects a tsup build-time define
+// (__PACKAGE_VERSION__) that is absent under tsx. Importing providers/index still runs
+// initializeProviders(), so the registry is populated. The build/tsc step verifies the index barrel.
+import { getProvider, getAllProviders, getProviderConfig, getAvailableProviders } from "../src/providers/index";
+import { parsePath, getSegment } from "../src/pathUtils";
+import { navigateToPath } from "../src/instructionPathUtils";
+import { detectMediaType, isMediaFile, createFolder, createFile } from "../src/utils";
+// instructionsToPlaylist was relocated from FormatConverters into utils (a general content
+// converter used by B1Church and the playground). Behavior must hold across the move.
+import { instructionsToPlaylist } from "../src/utils";
+import { getPlaylistWithMeta } from "../playground/formats";
+
+const EXPECTED_IDS = "dropbox lessonschurch aplay jesusfilm signpresenter b1church bibleproject planningcenter cbn highvoltagekids lifechurch".split(" ");
+const COMING_SOON_IDS = "awana freeshow gocurriculum iteachchurch ministrystuff".split(" ");
+const DEVICE_FLOW_IDS = new Set(["signpresenter", "b1church", "cbn"]);
+
+// --- Registry ---
+
+test("registry holds exactly the 11 built-in providers", () => {
+ const all = getAllProviders();
+ assert.equal(all.length, EXPECTED_IDS.length);
+ const ids = all.map(p => p.id).sort();
+ assert.deepEqual(ids, [...EXPECTED_IDS].sort());
+});
+
+test("getProvider resolves every built-in id and returns null otherwise", () => {
+ for (const id of EXPECTED_IDS) {
+ const provider = getProvider(id);
+ assert.ok(provider, `expected provider for ${id}`);
+ assert.equal(provider.id, id);
+ }
+ assert.equal(getProvider("does-not-exist"), null);
+});
+
+test("getProviderConfig returns the provider config or null", () => {
+ const config = getProviderConfig("signpresenter");
+ assert.ok(config);
+ assert.equal(config.id, "signpresenter");
+ assert.equal(getProviderConfig("nope"), null);
+});
+
+test("getAvailableProviders lists implemented + coming-soon and filters by id", () => {
+ const all = getAvailableProviders();
+ const implemented = all.filter(p => p.implemented);
+ assert.equal(implemented.length, EXPECTED_IDS.length);
+ for (const id of COMING_SOON_IDS) {
+ const info = all.find(p => p.id === id);
+ assert.ok(info, `expected coming-soon entry for ${id}`);
+ assert.equal(info.implemented, false);
+ }
+ const filtered = getAvailableProviders(["dropbox", "awana"]);
+ assert.equal(filtered.length, 2);
+});
+
+// --- Provider contract (data-driven over all providers) ---
+
+test("every provider satisfies the IProvider shape", () => {
+ for (const provider of getAllProviders()) {
+ assert.equal(typeof provider.id, "string");
+ assert.equal(typeof provider.name, "string");
+ assert.equal(provider.config.id, provider.id);
+ assert.equal(typeof provider.requiresAuth, "boolean");
+ assert.ok(Array.isArray(provider.authTypes));
+ const caps = provider.capabilities;
+ for (const key of ["browse", "playlist", "instructions", "mediaLicensing"]) {
+ assert.equal(typeof (caps as Record)[key], "boolean", `${provider.id}.capabilities.${key}`);
+ }
+ assert.equal(typeof provider.browse, "function");
+ assert.equal(typeof provider.supportsDeviceFlow, "function");
+ assert.equal(typeof provider.supportsDeviceFlow(), "boolean");
+ }
+});
+
+test("supportsDeviceFlow matches the device-flow providers", () => {
+ for (const provider of getAllProviders()) {
+ assert.equal(provider.supportsDeviceFlow(), DEVICE_FLOW_IDS.has(provider.id), provider.id);
+ }
+});
+
+// --- Auth surface that B1Admin's ContentProviderAuthManager drives generically ---
+
+test("device-flow providers expose initiate/poll methods (B1Admin contract)", () => {
+ for (const id of DEVICE_FLOW_IDS) {
+ const provider = getProvider(id) as any;
+ assert.equal(typeof provider.initiateDeviceFlow, "function", `${id}.initiateDeviceFlow`);
+ assert.equal(typeof provider.pollDeviceFlowToken, "function", `${id}.pollDeviceFlowToken`);
+ }
+});
+
+test("signpresenter exposes the full PKCE method set (B1Admin contract)", () => {
+ const provider = getProvider("signpresenter") as any;
+ for (const method of ["generateCodeVerifier", "buildAuthUrl", "exchangeCodeForTokens"]) {
+ assert.equal(typeof provider[method], "function", `signpresenter.${method}`);
+ }
+});
+
+test("dropbox exposes its RN-specific auth methods and no device flow (FreePlay contract)", () => {
+ const provider = getProvider("dropbox") as any;
+ assert.equal(typeof provider.buildAuthUrlFromChallenge, "function");
+ assert.equal(typeof provider.exchangeCodeForTokens, "function");
+ assert.equal(provider.supportsDeviceFlow(), false);
+});
+
+test("generateCodeVerifier returns a 64-char PKCE string", () => {
+ const provider = getProvider("signpresenter") as any;
+ const verifier: string = provider.generateCodeVerifier();
+ assert.equal(verifier.length, 64);
+ assert.match(verifier, /^[A-Za-z0-9\-._~]{64}$/);
+});
+
+// --- Offline browse (depth 0 is pure, no network) ---
+
+test("depth-0 browse returns the provider root folder without network", async () => {
+ const sign = await getProvider("signpresenter")!.browse(null);
+ assert.equal(sign.length, 1);
+ assert.equal(sign[0].type, "folder");
+ assert.equal((sign[0] as any).path, "/playlists");
+
+ const aplay = await getProvider("aplay")!.browse(null);
+ assert.equal((aplay[0] as any).path, "/modules");
+ assert.equal((aplay[0] as any).title, "Modules");
+
+ const b1 = await getProvider("b1church")!.browse(null);
+ assert.equal((b1[0] as any).path, "/ministries");
+});
+
+// --- pathUtils ---
+
+test("parsePath splits segments and computes depth", () => {
+ assert.deepEqual(parsePath("/a/b/c"), { segments: ["a", "b", "c"], depth: 3 });
+ assert.deepEqual(parsePath("a/b"), { segments: ["a", "b"], depth: 2 });
+ for (const empty of [null, undefined, "", "/"]) {
+ assert.deepEqual(parsePath(empty as string), { segments: [], depth: 0 });
+ }
+});
+
+test("getSegment returns the indexed segment or null", () => {
+ assert.equal(getSegment("/a/b/c", 1), "b");
+ assert.equal(getSegment("/a", 5), null);
+});
+
+// --- instructionPathUtils (used by B1Admin + B1App) ---
+
+test("navigateToPath walks the dot-notation tree", () => {
+ const tree = {
+ name: "T",
+ items: [
+ { id: "a", label: "A", children: [{ id: "a0", label: "A0" }, { id: "a1", label: "A1", children: [{ id: "a1x", label: "A1X" }] }] },
+ { id: "b", label: "B" }
+ ]
+ };
+ assert.equal(navigateToPath(tree, "0")?.id, "a");
+ assert.equal(navigateToPath(tree, "1")?.id, "b");
+ assert.equal(navigateToPath(tree, "0.1")?.id, "a1");
+ assert.equal(navigateToPath(tree, "0.1.0")?.id, "a1x");
+ assert.equal(navigateToPath(tree, "9"), null);
+ assert.equal(navigateToPath(tree, ""), null);
+ assert.equal(navigateToPath(tree, "x"), null);
+});
+
+// --- utils ---
+
+test("detectMediaType honours explicit type then extension", () => {
+ assert.equal(detectMediaType("https://x/clip.mp4"), "video");
+ assert.equal(detectMediaType("https://x/pic.jpg"), "image");
+ assert.equal(detectMediaType("https://stream.mux.com/abc"), "video");
+ assert.equal(detectMediaType("https://x/file", "video"), "video");
+ assert.equal(detectMediaType("https://x/file", "image/png"), "image");
+ assert.equal(detectMediaType("https://x/no-extension"), "image");
+});
+
+test("isMediaFile / createFolder / createFile build the expected shapes", () => {
+ assert.equal(isMediaFile("a.mp4"), true);
+ assert.equal(isMediaFile("a.txt"), false);
+
+ const folder = createFolder("f1", "Folder", "/p", "thumb.jpg", true);
+ assert.equal(folder.type, "folder");
+ assert.equal(folder.id, "f1");
+ assert.equal(folder.path, "/p");
+ assert.equal(folder.isLeaf, true);
+
+ const file = createFile("c1", "Clip", "https://x/clip.mp4");
+ assert.equal(file.type, "file");
+ assert.equal(file.mediaType, "video");
+ assert.equal(file.url, "https://x/clip.mp4");
+});
+
+// --- instructionsToPlaylist (relocated function: behavior must be preserved) ---
+
+test("instructionsToPlaylist flattens downloadable leaves into files", () => {
+ const instructions = {
+ name: "Plan",
+ items: [
+ {
+ id: "sec",
+ itemType: "section",
+ label: "Section",
+ children: [{ id: "f1", itemType: "file", label: "Clip One", downloadUrl: "https://x/v.mp4", seconds: 10 }]
+ }
+ ]
+ };
+ const files = instructionsToPlaylist(instructions);
+ assert.equal(files.length, 1);
+ assert.equal(files[0].type, "file");
+ assert.equal(files[0].id, "f1");
+ assert.equal(files[0].url, "https://x/v.mp4");
+ assert.equal(files[0].mediaType, "video");
+ assert.equal(files[0].seconds, 10);
+});
+
+// --- Playground shim fallback (regression: Jesus Film / High Voltage Kids return null from
+// getPlaylist at collection depth, so the playlist view must derive from instructions) ---
+
+test("getPlaylistWithMeta derives a playlist from instructions when getPlaylist yields nothing", async () => {
+ const stub = {
+ capabilities: { browse: true, playlist: true, instructions: true, mediaLicensing: false },
+ getPlaylist: async () => null,
+ getInstructions: async () => ({ name: "S", items: [{ id: "f1", itemType: "file", label: "Clip", downloadUrl: "https://x/v.mp4", seconds: 5 }] })
+ } as any;
+
+ const { data, meta } = await getPlaylistWithMeta(stub, "/collection", null);
+ assert.equal(data?.length, 1);
+ assert.equal(data?.[0].url, "https://x/v.mp4");
+ assert.equal(meta.isNative, false);
+ assert.equal(meta.sourceFormat, "instructions");
+});
+
+test("getPlaylistWithMeta prefers the native playlist when getPlaylist returns files", async () => {
+ const stub = {
+ capabilities: { browse: true, playlist: true, instructions: true, mediaLicensing: false },
+ getPlaylist: async () => [{ type: "file", id: "n1", title: "Native", mediaType: "video", url: "https://x/n.mp4" }],
+ getInstructions: async () => { throw new Error("instructions fallback should not run when playlist is native"); }
+ } as any;
+
+ const { data, meta } = await getPlaylistWithMeta(stub, "/leaf", null);
+ assert.equal(data?.[0].id, "n1");
+ assert.equal(meta.isNative, true);
+});
diff --git a/helpers/CHANGELOG.md b/helpers/CHANGELOG.md
index 9770649..5b23d56 100644
--- a/helpers/CHANGELOG.md
+++ b/helpers/CHANGELOG.md
@@ -1,5 +1,21 @@
# @churchapps/helpers
+## 1.8.1
+
+### Patch Changes
+
+- 39e47d3: Image craft + responsive image performance for the website builder.
+
+ apphelper:
+
+ - New pluggable image-optimizer seam (`setImageOptimizer` / `responsiveImgProps` / `optimizedBackgroundImage`). Default is identity, so non-Next hosts (the Vite editor) render plain `
` unchanged; B1App registers a Next.js `getImageProps`-backed optimizer to emit `srcset`/`sizes` and WebP/AVIF backgrounds.
+ - Content images (`image`, `card`, `textWithPhoto`, `logo`) now route their `src` through the seam; `logo` also gains the missing `loading="lazy" decoding="async"`.
+ - `BoxElement` image backgrounds get optimized loading, a `focalPoint` (`background-position`), and an opt-in tint overlay (`.boxBG:before`, driven by `--overlay-color` / `--overlay-opacity`, default off).
+
+ helpers:
+
+ - Documented the new `box` answer keys (`focalPoint`, `overlayColor`, `backgroundOpacity`) in `ElementTypes`.
+
## 1.8.0
### Minor Changes
diff --git a/helpers/package.json b/helpers/package.json
index 77ae7f3..74668d8 100644
--- a/helpers/package.json
+++ b/helpers/package.json
@@ -1,6 +1,6 @@
{
"name": "@churchapps/helpers",
- "version": "1.8.0",
+ "version": "1.8.1",
"type": "module",
"description": "Library of helper functions not specific to any one ChurchApps project or framework.",
"main": "dist/index.js",
diff --git a/helpers/src/ElementTypes.ts b/helpers/src/ElementTypes.ts
index e988408..221026b 100644
--- a/helpers/src/ElementTypes.ts
+++ b/helpers/src/ElementTypes.ts
@@ -66,6 +66,9 @@ export const ElementTypes: Record = {
rounded: { type: "string", enum: ["true", "false"], description: "Checkbox boolean stored as the string \"true\"/\"false\"." },
translucent: { type: "string", enum: ["true", "false"], description: "Checkbox boolean stored as the string \"true\"/\"false\"." },
background: { type: "string", description: "Color hex, CSS var token (var(--light)), or image URL (any value containing \"/\" is treated as a background image)." },
+ focalPoint: { type: "string", description: "CSS background-position string (e.g. \"50% 30%\") applied when background is an image; keeps the chosen point in view when the cover-cropped image is scaled. Defaults to center." },
+ overlayColor: { type: "string", description: "Color hex of the tint overlay drawn over an image background via the .boxBG:before layer (default #000)." },
+ backgroundOpacity: { type: ["number", "string"], description: "Opacity 0-1 of the image background overlay; defaults to 0 (no overlay) for boxes." },
textColor: { type: "string", description: "Color hex or CSS var token; only var(...) values are applied as CSS color by the renderer." },
headingColor: { type: "string", description: "CSS var token; mapped to a headings* CSS class by the renderer." },
linkColor: { type: "string", description: "CSS var token; mapped to a links* CSS class by the renderer." },
diff --git a/yarn.lock b/yarn.lock
index 412dd2f..31d8ded 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -904,7 +904,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@churchapps/apphelper@workspace:apphelper"
dependencies:
- "@churchapps/helpers": "npm:^1.7.1"
+ "@churchapps/helpers": "npm:^1.8.1"
"@emotion/cache": "npm:^11.14.0"
"@emotion/react": "npm:^11.14.0"
"@emotion/styled": "npm:^11.14.1"
@@ -1013,7 +1013,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@churchapps/helpers@npm:^1.6.0, @churchapps/helpers@npm:^1.7.1, @churchapps/helpers@workspace:helpers":
+"@churchapps/helpers@npm:^1.6.0, @churchapps/helpers@npm:^1.8.1, @churchapps/helpers@workspace:helpers":
version: 0.0.0-use.local
resolution: "@churchapps/helpers@workspace:helpers"
dependencies: