Compare commits

...

10 Commits

Author SHA1 Message Date
Dmitri
027af5082b updated page dropdown in the constructor 2026-05-08 16:44:25 +02:00
Dmitri
06a29dbf6a improved video processing, pages navigation and assets preloading 2026-05-08 07:54:30 +02:00
Dmitri
ba813d2602 improved preloading functionality 2026-05-06 10:04:42 +02:00
Dmitri
f06a2b2c97 improved menus in constructor 2026-05-05 17:25:53 +02:00
Dmitri
4634ad9207 simplified video preloading 2026-05-04 23:09:37 +02:00
Dmitri
2073bee244 Added Maple Medium font to the platform 2026-05-04 18:49:29 +02:00
Dmitri
6581fd70c2 formatting 2026-05-04 17:30:32 +02:00
Dmitri
e4dd94f478 added fade effects to gallery and for carousel slides. 2026-05-04 16:49:43 +02:00
Dmitri
e855db03d1 added configured fades for projects pages 2026-05-04 12:18:56 +02:00
Dmitri
5ef21543b3 fixed save button timestamp 2026-04-30 13:14:29 +02:00
115 changed files with 10098 additions and 3657 deletions

View File

@ -0,0 +1,155 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
/**
* Global Transition Defaults API
*
* Single-row table pattern for platform-wide transition settings.
* Auto-seeds default values if the table is empty.
*/
class Global_transition_defaultsDBApi extends GenericDBApi {
static get MODEL() {
return db.global_transition_defaults;
}
static get TABLE_NAME() {
return 'global_transition_defaults';
}
static get SEARCHABLE_FIELDS() {
return [];
}
static get RANGE_FIELDS() {
return [];
}
static get ENUM_FIELDS() {
return ['transition_type', 'easing'];
}
static get CSV_FIELDS() {
return [
'id',
'transition_type',
'duration_ms',
'easing',
'overlay_color',
'createdAt',
'updatedAt',
];
}
static get AUTOCOMPLETE_FIELD() {
return 'transition_type';
}
static get FIELD_DEFAULTS() {
return {
transition_type: { default: 'fade' },
duration_ms: { default: 700 },
easing: { default: 'ease-in-out' },
overlay_color: { default: '#000000' },
};
}
static get DEFAULT_ROW() {
return {
transition_type: 'fade',
duration_ms: 700,
easing: 'ease-in-out',
overlay_color: '#000000',
};
}
static getFieldMapping(data) {
const mapped = super.getFieldMapping(data);
return {
id: mapped.id || undefined,
transition_type: mapped.transition_type,
duration_ms: mapped.duration_ms,
easing: mapped.easing,
overlay_color: mapped.overlay_color,
};
}
/**
* Ensures the singleton row exists.
* Creates the default row if table is empty.
*/
static async ensureInitialized() {
if (!this.initializationPromise) {
this.initializationPromise = (async () => {
let count = 0;
try {
count = await this.MODEL.count();
} catch (error) {
// Table doesn't exist yet (happens during initial migration)
if (error?.original?.code !== '42P01') {
throw error;
}
await this.MODEL.sync();
count = await this.MODEL.count();
}
if (count > 0) return;
const now = new Date();
await this.MODEL.create({
...this.getFieldMapping(this.DEFAULT_ROW),
createdAt: now,
updatedAt: now,
});
})().catch((error) => {
this.initializationPromise = null;
throw error;
});
}
await this.initializationPromise;
}
/**
* Get the singleton row.
* Always returns a single object, not an array.
*/
static async findOne(options = {}) {
await this.ensureInitialized();
const record = await this.MODEL.findOne({
transaction: options.transaction,
});
if (!record) return null;
return record.get({ plain: true });
}
/**
* Alias for findOne to maintain semantic clarity.
*/
static async get(options = {}) {
return this.findOne(options);
}
static async update(id, data, options = {}) {
await this.ensureInitialized();
return super.update(id, data, options);
}
static async findBy(where, options = {}) {
await this.ensureInitialized();
return super.findBy(where, options);
}
static async findAll(filter = {}, options = {}) {
await this.ensureInitialized();
return super.findAll(filter, options);
}
}
Global_transition_defaultsDBApi.initializationPromise = null;
module.exports = Global_transition_defaultsDBApi;

View File

@ -0,0 +1,277 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const {
applyRuntimeEnvironment,
applyRuntimeProjectFilter,
} = require('./runtime-context');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class Project_transition_settingsDBApi extends GenericDBApi {
static get MODEL() {
return db.project_transition_settings;
}
static get TABLE_NAME() {
return 'project_transition_settings';
}
static get SEARCHABLE_FIELDS() {
return ['source_key', 'transition_type', 'easing', 'overlay_color'];
}
static get RANGE_FIELDS() {
return ['duration_ms'];
}
static get ENUM_FIELDS() {
return ['environment'];
}
static get CSV_FIELDS() {
return [
'id',
'environment',
'source_key',
'transition_type',
'duration_ms',
'easing',
'overlay_color',
'createdAt',
];
}
static get AUTOCOMPLETE_FIELD() {
return 'transition_type';
}
static get ASSOCIATIONS() {
return [{ field: 'project', setter: 'setProject', isArray: false }];
}
static getFieldMapping(data) {
// Note: environment and projectId are NOT included here because they are
// set explicitly in upsertForProject and should never be changed via data
return {
id: data.id || undefined,
source_key: data.source_key || null,
transition_type: data.transition_type || 'fade',
duration_ms: data.duration_ms !== undefined ? data.duration_ms : 700,
easing: data.easing || 'ease-in-out',
overlay_color: data.overlay_color || '#000000',
};
}
/**
* Find settings by project ID and environment.
* This is the primary method for fetching transition settings.
*
* @param {string} projectId - Project ID
* @param {string} environment - Environment (dev, stage, production)
* @param {object} options - Query options
* @returns {object|null} Settings record or null
*/
static async findByProjectAndEnvironment(
projectId,
environment,
options = {},
) {
const transaction = options.transaction;
const record = await this.MODEL.findOne({
where: {
projectId,
environment,
},
transaction,
include: [
{
model: db.projects,
as: 'project',
},
],
});
if (!record) return null;
return record.get({ plain: true });
}
/**
* Create or update settings for a project/environment combination.
* Uses upsert semantics - creates if not exists, updates if exists.
*
* @param {string} projectId - Project ID
* @param {string} environment - Environment (dev, stage, production)
* @param {object} data - Settings data
* @param {object} options - Query options
* @returns {object} Created or updated record
*/
static async upsertForProject(projectId, environment, data, options = {}) {
const transaction = options.transaction;
const currentUser = options.currentUser;
// Check if record exists
const existing = await this.MODEL.findOne({
where: { projectId, environment },
transaction,
});
if (existing) {
// Update existing record
await existing.update(
{
...this.getFieldMapping(data),
updatedById: currentUser?.id || null,
},
{ transaction },
);
return existing.get({ plain: true });
}
// Create new record
const newRecord = await this.MODEL.create(
{
...this.getFieldMapping(data),
projectId,
environment,
createdById: currentUser?.id || null,
updatedById: currentUser?.id || null,
},
{ transaction },
);
return newRecord.get({ plain: true });
}
static async findBy(where, options = {}) {
const transaction = options.transaction;
const queryWhere = applyRuntimeEnvironment({ ...where }, options);
const projectInclude = applyRuntimeProjectFilter(
{ model: db.projects, as: 'project' },
options,
);
const record = await this.MODEL.findOne({
where: queryWhere,
transaction,
include: [projectInclude],
});
if (!record) return null;
return record.get({ plain: true });
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = +filter.page || 0;
const offset = currentPage * limit;
let where = {};
const terms = filter.project ? filter.project.split('|') : [];
const validUuids = Utils.filterValidUuids(terms);
let include = [
{
model: db.projects,
as: 'project',
where: filter.project
? {
[Op.or]: [
...(validUuids.length > 0
? [{ id: { [Op.in]: validUuids } }]
: []),
{
name: {
[Op.or]: terms.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
];
include[0] = applyRuntimeProjectFilter(include[0], options);
if (filter.id) {
if (!Utils.isValidUuid(filter.id)) {
return { rows: [], count: 0 };
}
where.id = filter.id;
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
where = applyRuntimeEnvironment(where, options);
const queryOptions = {
where,
include,
distinct: true,
order:
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
}
module.exports = Project_transition_settingsDBApi;

View File

@ -47,17 +47,20 @@ class ProjectsDBApi extends GenericDBApi {
} }
static getFieldMapping(data) { static getFieldMapping(data) {
// Use undefined for missing fields so they're skipped during update
// Only include fields that are explicitly provided in data
// Note: transition_settings moved to project_transition_settings table
return { return {
id: data.id || undefined, id: data.id || undefined,
name: data.name || null, name: 'name' in data ? data.name || null : undefined,
slug: data.slug || null, slug: 'slug' in data ? data.slug || null : undefined,
description: data.description || null, description: 'description' in data ? data.description || null : undefined,
logo_url: data.logo_url || null, logo_url: 'logo_url' in data ? data.logo_url || null : undefined,
favicon_url: data.favicon_url || null, favicon_url: 'favicon_url' in data ? data.favicon_url || null : undefined,
og_image_url: data.og_image_url || null, og_image_url:
design_width: data.design_width !== undefined ? data.design_width : null, 'og_image_url' in data ? data.og_image_url || null : undefined,
design_height: design_width: 'design_width' in data ? data.design_width : undefined,
data.design_height !== undefined ? data.design_height : null, design_height: 'design_height' in data ? data.design_height : undefined,
}; };
} }

View File

@ -0,0 +1,103 @@
'use strict';
const { v4: uuidv4 } = require('uuid');
/**
* Migration: Add hierarchical transition settings
*
* Creates global_transition_defaults table for platform-wide transition settings
* and adds transition_settings JSONB column to projects table for project-level overrides.
*
* Cascade: Element Project Global (fallback)
*
* @type {import('sequelize-cli').Migration}
*/
module.exports = {
async up(queryInterface, Sequelize) {
// Create global_transition_defaults table (single-row pattern)
await queryInterface.createTable('global_transition_defaults', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
transition_type: {
type: Sequelize.TEXT,
allowNull: false,
defaultValue: 'fade',
},
duration_ms: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 700,
},
easing: {
type: Sequelize.TEXT,
allowNull: false,
defaultValue: 'ease-in-out',
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
createdById: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'users',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
updatedById: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'users',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
});
// Seed the default row
const now = new Date();
await queryInterface.bulkInsert('global_transition_defaults', [
{
id: uuidv4(),
transition_type: 'fade',
duration_ms: 700,
easing: 'ease-in-out',
createdAt: now,
updatedAt: now,
},
]);
// Add transition_settings JSONB column to projects
await queryInterface.addColumn('projects', 'transition_settings', {
type: Sequelize.JSONB,
allowNull: true,
defaultValue: null,
});
},
async down(queryInterface, _Sequelize) {
// Remove transition_settings from projects
await queryInterface.removeColumn('projects', 'transition_settings');
// Drop global_transition_defaults table
await queryInterface.dropTable('global_transition_defaults');
},
};

View File

@ -0,0 +1,76 @@
'use strict';
/**
* Migration: Simplify transitions and add overlay color
*
* 1. Add overlay_color column to global_transition_defaults
* 2. Update global_transition_defaults: change slide-left/slide-right/zoom to 'fade'
* 3. Update projects.transition_settings JSONB where transitionType is slide/zoom
*/
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
// 1. Add overlay_color column to global_transition_defaults
await queryInterface.addColumn(
'global_transition_defaults',
'overlay_color',
{
type: Sequelize.TEXT,
allowNull: false,
defaultValue: '#000000',
},
{ transaction },
);
// 2. Update global_transition_defaults: change slide-left/slide-right/zoom to 'fade'
await queryInterface.sequelize.query(
`UPDATE global_transition_defaults
SET transition_type = 'fade'
WHERE transition_type IN ('slide-left', 'slide-right', 'zoom')`,
{ transaction },
);
// 3. Update projects.transition_settings JSONB where transitionType is slide/zoom
// Convert slide-left, slide-right, zoom to 'fade'
await queryInterface.sequelize.query(
`UPDATE projects
SET transition_settings = jsonb_set(
COALESCE(transition_settings, '{}'::jsonb),
'{transitionType}',
'"fade"'
)
WHERE transition_settings IS NOT NULL
AND transition_settings->>'transitionType' IN ('slide-left', 'slide-right', 'zoom')`,
{ transaction },
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface, _Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
// Remove overlay_color column
await queryInterface.removeColumn(
'global_transition_defaults',
'overlay_color',
{ transaction },
);
// Note: We cannot restore the original slide/zoom values as they are lost
// The data migration is one-way
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -0,0 +1,231 @@
'use strict';
/**
* Migration: Create project_transition_settings table
*
* Creates environment-aware project transition settings following the
* project_audio_tracks pattern. This allows transition settings to be
* isolated per environment and participate in the publishing workflow.
*
* Data migration:
* - Existing projects.transition_settings values are copied to 'dev' environment records
* - The column is dropped after migration to avoid dual storage
*/
const { v4: uuidv4 } = require('uuid');
module.exports = {
async up(queryInterface, Sequelize) {
// Step 1: Create the project_transition_settings table
await queryInterface.createTable('project_transition_settings', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
primaryKey: true,
},
projectId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: 'projects',
key: 'id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
environment: {
type: Sequelize.ENUM('dev', 'stage', 'production'),
allowNull: false,
},
source_key: {
type: Sequelize.TEXT,
allowNull: true,
},
transition_type: {
type: Sequelize.TEXT,
allowNull: false,
defaultValue: 'fade',
},
duration_ms: {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 700,
},
easing: {
type: Sequelize.TEXT,
allowNull: false,
defaultValue: 'ease-in-out',
},
overlay_color: {
type: Sequelize.TEXT,
allowNull: false,
defaultValue: '#000000',
},
createdById: {
type: Sequelize.UUID,
references: {
model: 'users',
key: 'id',
},
allowNull: true,
},
updatedById: {
type: Sequelize.UUID,
references: {
model: 'users',
key: 'id',
},
allowNull: true,
},
createdAt: {
allowNull: false,
type: Sequelize.DATE,
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE,
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
importHash: {
type: Sequelize.STRING(255),
allowNull: true,
unique: true,
},
});
// Add unique constraint on (projectId, environment)
// Use IF NOT EXISTS to avoid errors if index already exists
await queryInterface.sequelize.query(`
CREATE UNIQUE INDEX IF NOT EXISTS project_transition_settings_project_env_unique
ON project_transition_settings ("projectId", environment)
WHERE "deletedAt" IS NULL
`);
// Add index on deletedAt for soft delete queries
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS project_transition_settings_deleted_at
ON project_transition_settings ("deletedAt")
`);
// Step 2: Migrate existing project.transition_settings data to 'dev' records
const [projects] = await queryInterface.sequelize.query(`
SELECT id, transition_settings, "createdById", "updatedById"
FROM projects
WHERE transition_settings IS NOT NULL
AND transition_settings != 'null'
AND "deletedAt" IS NULL
`);
const now = new Date();
const records = [];
for (const project of projects) {
let settings = project.transition_settings;
// Parse JSONB if it's a string
if (typeof settings === 'string') {
try {
settings = JSON.parse(settings);
} catch (e) {
console.warn(
`Failed to parse transition_settings for project ${project.id}:`,
e,
);
continue;
}
}
// Skip if settings is null, empty object, or has no actual values
if (
!settings ||
typeof settings !== 'object' ||
Object.keys(settings).length === 0
) {
continue;
}
records.push({
id: uuidv4(),
projectId: project.id,
environment: 'dev',
source_key: null,
transition_type: settings.transitionType || 'fade',
duration_ms: settings.durationMs || 700,
easing: settings.easing || 'ease-in-out',
overlay_color: settings.overlayColor || '#000000',
createdById: project.createdById || null,
updatedById: project.updatedById || null,
createdAt: now,
updatedAt: now,
deletedAt: null,
importHash: null,
});
}
if (records.length > 0) {
await queryInterface.bulkInsert('project_transition_settings', records);
console.log(
`Migrated ${records.length} project transition settings to 'dev' environment`,
);
}
// Step 3: Drop the transition_settings column from projects table
await queryInterface.removeColumn('projects', 'transition_settings');
console.log('Dropped transition_settings column from projects table');
},
async down(queryInterface, Sequelize) {
// Step 1: Re-add the transition_settings column to projects
await queryInterface.addColumn('projects', 'transition_settings', {
type: Sequelize.JSONB,
allowNull: true,
defaultValue: null,
});
// Step 2: Migrate 'dev' records back to projects.transition_settings
const [settings] = await queryInterface.sequelize.query(`
SELECT "projectId", transition_type, duration_ms, easing, overlay_color
FROM project_transition_settings
WHERE environment = 'dev'
AND "deletedAt" IS NULL
`);
for (const setting of settings) {
const jsonValue = JSON.stringify({
transitionType: setting.transition_type,
durationMs: setting.duration_ms,
easing: setting.easing,
overlayColor: setting.overlay_color,
});
await queryInterface.sequelize.query(
`
UPDATE projects
SET transition_settings = :settings::jsonb
WHERE id = :projectId
`,
{
replacements: {
settings: jsonValue,
projectId: setting.projectId,
},
},
);
}
// Step 3: Drop indexes and table
await queryInterface.sequelize.query(`
DROP INDEX IF EXISTS project_transition_settings_project_env_unique;
DROP INDEX IF EXISTS project_transition_settings_deleted_at;
`);
await queryInterface.dropTable('project_transition_settings');
// Drop the ENUM type
await queryInterface.sequelize.query(
'DROP TYPE IF EXISTS "enum_project_transition_settings_environment";',
);
},
};

View File

@ -0,0 +1,73 @@
module.exports = function (sequelize, DataTypes) {
const global_transition_defaults = sequelize.define(
'global_transition_defaults',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
transition_type: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'fade',
validate: {
notEmpty: { msg: 'Transition type is required' },
isIn: {
args: [['fade', 'none']],
msg: 'Invalid transition type',
},
},
},
overlay_color: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: '#000000',
validate: {
notEmpty: { msg: 'Overlay color is required' },
},
},
duration_ms: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 700,
validate: {
isInt: { msg: 'Duration must be an integer' },
min: {
args: [0],
msg: 'Duration must be at least 0ms',
},
},
},
easing: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'ease-in-out',
validate: {
notEmpty: { msg: 'Easing is required' },
isIn: {
args: [['ease-in-out', 'ease-in', 'ease-out', 'linear']],
msg: 'Invalid easing function',
},
},
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
},
);
global_transition_defaults.associate = (db) => {
db.global_transition_defaults.belongsTo(db.users, {
as: 'createdBy',
});
db.global_transition_defaults.belongsTo(db.users, {
as: 'updatedBy',
});
};
return global_transition_defaults;
};

View File

@ -0,0 +1,103 @@
module.exports = function (sequelize, DataTypes) {
const project_transition_settings = sequelize.define(
'project_transition_settings',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
environment: {
type: DataTypes.ENUM,
values: ['dev', 'stage', 'production'],
allowNull: false,
},
source_key: {
type: DataTypes.TEXT,
allowNull: true,
},
transition_type: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'fade',
validate: {
isIn: {
args: [['fade', 'none', 'video']],
msg: 'Transition type must be fade, none, or video',
},
},
},
duration_ms: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 700,
validate: {
min: { args: [0], msg: 'Duration must be at least 0ms' },
max: { args: [10000], msg: 'Duration must be at most 10000ms' },
},
},
easing: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: 'ease-in-out',
validate: {
isIn: {
args: [['ease-in-out', 'ease-in', 'ease-out', 'linear']],
msg: 'Easing must be ease-in-out, ease-in, ease-out, or linear',
},
},
},
overlay_color: {
type: DataTypes.TEXT,
allowNull: false,
defaultValue: '#000000',
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
unique: true,
},
},
{
timestamps: true,
paranoid: true,
freezeTableName: true,
indexes: [
{
fields: ['projectId', 'environment'],
unique: true,
where: { deletedAt: null },
},
],
},
);
project_transition_settings.associate = (db) => {
db.project_transition_settings.belongsTo(db.projects, {
as: 'project',
foreignKey: {
name: 'projectId',
},
constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
});
db.project_transition_settings.belongsTo(db.users, {
as: 'createdBy',
});
db.project_transition_settings.belongsTo(db.users, {
as: 'updatedBy',
});
};
return project_transition_settings;
};

View File

@ -65,6 +65,9 @@ module.exports = function (sequelize, DataTypes) {
defaultValue: 1080, defaultValue: 1080,
}, },
// Note: transition_settings moved to project_transition_settings table
// for environment-aware storage (dev, stage, production)
importHash: { importHash: {
type: DataTypes.STRING(255), type: DataTypes.STRING(255),
allowNull: true, allowNull: true,
@ -172,6 +175,16 @@ module.exports = function (sequelize, DataTypes) {
onUpdate: 'CASCADE', onUpdate: 'CASCADE',
}); });
db.projects.hasMany(db.project_transition_settings, {
as: 'project_transition_settings_project',
foreignKey: {
name: 'projectId',
},
constraints: true,
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
});
//end loop //end loop
db.projects.belongsTo(db.users, { db.projects.belongsTo(db.users, {

View File

@ -51,6 +51,8 @@ const pwa_cachesRoutes = require('./routes/pwa_caches');
const access_logsRoutes = require('./routes/access_logs'); const access_logsRoutes = require('./routes/access_logs');
const element_type_defaultsRoutes = require('./routes/element_type_defaults'); const element_type_defaultsRoutes = require('./routes/element_type_defaults');
const project_element_defaultsRoutes = require('./routes/project_element_defaults'); const project_element_defaultsRoutes = require('./routes/project_element_defaults');
const global_transition_defaultsRoutes = require('./routes/global_transition_defaults');
const project_transition_settingsRoutes = require('./routes/project_transition_settings');
const publishRoutes = require('./routes/publish'); const publishRoutes = require('./routes/publish');
const runtimeContextRoutes = require('./routes/runtime-context'); const runtimeContextRoutes = require('./routes/runtime-context');
@ -141,8 +143,8 @@ app.use('/api/file/upload-sessions', uploadLimiter);
app.use('/api/file', fileRoutes); app.use('/api/file', fileRoutes);
// Body parser for all other routes // Body parser for all other routes
app.use(bodyParser.json({ limit: '1mb' })); app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ extended: true, limit: '1mb' })); app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
app.use(runtimeContextMiddleware); app.use(runtimeContextMiddleware);
const requireRuntimeReadOrAuth = (req, res, next) => { const requireRuntimeReadOrAuth = (req, res, next) => {
@ -198,7 +200,7 @@ const mountRuntimeEntityRoute = (path, entityName, router) => {
app.use( app.use(
path, path,
requireRuntimeReadOrAuth, requireRuntimeReadOrAuth,
blockNonPublicRuntimeListEndpoints, blockNonPublicRuntimeListEndpoints(entityName),
sanitizePublicRuntimeListResponse(entityName), sanitizePublicRuntimeListResponse(entityName),
router, router,
); );
@ -235,6 +237,11 @@ app.use(
jwtAuth, jwtAuth,
project_element_defaultsRoutes, project_element_defaultsRoutes,
); );
// Global transition defaults - routes handle their own auth (GET public, PUT protected)
app.use('/api/global-transition-defaults', global_transition_defaultsRoutes);
// Project transition settings - routes handle their own auth (production GET public, else protected)
app.use('/api/project-transition-settings', project_transition_settingsRoutes);
app.use('/api/publish', jwtAuth, publishRoutes); app.use('/api/publish', jwtAuth, publishRoutes);

View File

@ -161,6 +161,8 @@ const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
'PAGE_LINKS', 'PAGE_LINKS',
'TRANSITIONS', 'TRANSITIONS',
'PROJECT_AUDIO_TRACKS', 'PROJECT_AUDIO_TRACKS',
'GLOBAL_TRANSITION_DEFAULTS',
'PROJECT_TRANSITION_SETTINGS',
]); ]);
/** /**

View File

@ -1,5 +1,3 @@
const PUBLIC_RUNTIME_ALLOWED_PATH = '/';
const PUBLIC_RUNTIME_ENTITY_FIELDS = { const PUBLIC_RUNTIME_ENTITY_FIELDS = {
projects: [ projects: [
'id', 'id',
@ -38,6 +36,31 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
'sort_order', 'sort_order',
'is_enabled', 'is_enabled',
], ],
global_transition_defaults: [
'id',
'transition_type',
'duration_ms',
'easing',
'overlay_color',
],
project_transition_settings: [
'id',
'projectId',
'environment',
'transition_type',
'duration_ms',
'easing',
'overlay_color',
],
};
// Entity-aware path patterns for public runtime access
// Entities not listed here default to allowing only '/'
const PUBLIC_RUNTIME_ALLOWED_PATHS = {
project_transition_settings: [
'/',
/^\/project\/[a-fA-F0-9-]+\/env\/(dev|stage|production)$/,
],
}; };
const pickFields = (record, fields) => { const pickFields = (record, fields) => {
@ -61,12 +84,17 @@ const isPublicRuntimeReadRequest = (req) => {
return req.isRuntimePublicRequest === true && req.method === 'GET'; return req.isRuntimePublicRequest === true && req.method === 'GET';
}; };
const blockNonPublicRuntimeListEndpoints = (req, res, next) => { const blockNonPublicRuntimeListEndpoints = (entityName) => (req, res, next) => {
if (!isPublicRuntimeReadRequest(req)) { if (!isPublicRuntimeReadRequest(req)) {
return next(); return next();
} }
if (req.path !== PUBLIC_RUNTIME_ALLOWED_PATH) { const allowedPaths = PUBLIC_RUNTIME_ALLOWED_PATHS[entityName] || ['/'];
const pathMatches = allowedPaths.some((pattern) =>
pattern instanceof RegExp ? pattern.test(req.path) : req.path === pattern,
);
if (!pathMatches) {
return res.status(404).send({ message: 'Not found' }); return res.status(404).send({ message: 'Not found' });
} }
@ -79,11 +107,16 @@ const blockNonPublicRuntimeListEndpoints = (req, res, next) => {
const sanitizePublicRuntimeListResponse = (entityName) => { const sanitizePublicRuntimeListResponse = (entityName) => {
const fields = PUBLIC_RUNTIME_ENTITY_FIELDS[entityName] || []; const fields = PUBLIC_RUNTIME_ENTITY_FIELDS[entityName] || [];
const allowedPaths = PUBLIC_RUNTIME_ALLOWED_PATHS[entityName] || ['/'];
return (req, res, next) => { return (req, res, next) => {
const pathMatches = allowedPaths.some((pattern) =>
pattern instanceof RegExp ? pattern.test(req.path) : req.path === pattern,
);
if ( if (
!isPublicRuntimeReadRequest(req) || !isPublicRuntimeReadRequest(req) ||
req.path !== PUBLIC_RUNTIME_ALLOWED_PATH || !pathMatches ||
fields.length === 0 fields.length === 0
) { ) {
return next(); return next();
@ -96,16 +129,21 @@ const sanitizePublicRuntimeListResponse = (entityName) => {
return originalSend(body); return originalSend(body);
} }
if (!Array.isArray(body.rows)) { // Handle list responses with rows array
return originalSend(body); if (Array.isArray(body.rows)) {
const sanitizedRows = body.rows.map((row) => pickFields(row, fields));
return originalSend({
...body,
rows: sanitizedRows,
});
} }
const sanitizedRows = body.rows.map((row) => pickFields(row, fields)); // Handle single object responses (e.g., from findOne or project/:id/env/:env)
if (!Array.isArray(body) && body !== null) {
return originalSend(pickFields(body, fields));
}
return originalSend({ return originalSend(body);
...body,
rows: sanitizedRows,
});
}; };
return next(); return next();

View File

@ -0,0 +1,131 @@
const express = require('express');
const passport = require('passport');
const Global_transition_defaultsService = require('../services/global_transition_defaults');
const Global_transition_defaultsDBApi = require('../db/api/global_transition_defaults');
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
const { checkCrudPermissions } = require('../middlewares/check-permissions');
const router = express.Router();
const jwtAuth = passport.authenticate('jwt', { session: false });
/**
* Middleware for public GET access.
* Marks GET requests as public runtime requests for permission bypass.
* MUST run before checkCrudPermissions to set the flag first.
*/
const allowPublicRead = (req, _res, next) => {
if (['GET', 'OPTIONS'].includes(req.method)) {
req.isRuntimePublicRequest = true;
}
return next();
};
// Apply public read first, then CRUD permission checks
router.use(allowPublicRead);
router.use(checkCrudPermissions('global_transition_defaults'));
/**
* @swagger
* /api/global-transition-defaults:
* get:
* summary: Get global transition defaults (singleton)
* tags: [GlobalTransitionDefaults]
* description: Publicly accessible - no authentication required.
* responses:
* 200:
* description: Global transition defaults settings
*/
router.get(
'/',
wrapAsync(async (_req, res) => {
const payload = await Global_transition_defaultsDBApi.findOne();
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/global-transition-defaults/{id}:
* get:
* summary: Get global transition defaults by ID
* tags: [GlobalTransitionDefaults]
* description: Publicly accessible - no authentication required.
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* format: uuid
* responses:
* 200:
* description: Global transition defaults settings
*/
router.get(
'/:id',
wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) {
return res.status(400).send('Invalid global_transition_defaults id');
}
const payload = await Global_transition_defaultsDBApi.findBy({
id: req.params.id,
});
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/global-transition-defaults/{id}:
* put:
* summary: Update global transition defaults
* tags: [GlobalTransitionDefaults]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* format: uuid
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* data:
* type: object
* properties:
* transition_type:
* type: string
* enum: [fade, slide-left, slide-right, zoom, none]
* duration_ms:
* type: integer
* minimum: 0
* easing:
* type: string
* enum: [ease-in-out, ease-in, ease-out, linear]
* responses:
* 200:
* description: Update successful
*/
router.put(
'/:id',
jwtAuth,
wrapAsync(async (req, res) => {
await Global_transition_defaultsService.update(
req.body.data,
req.body.id,
req.currentUser,
);
res.status(200).send(true);
}),
);
router.use('/', commonErrorHandler);
module.exports = router;

View File

@ -0,0 +1,393 @@
const express = require('express');
const passport = require('passport');
const Project_transition_settingsService = require('../services/project_transition_settings');
const Project_transition_settingsDBApi = require('../db/api/project_transition_settings');
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
const { checkCrudPermissions } = require('../middlewares/check-permissions');
const router = express.Router();
const jwtAuth = passport.authenticate('jwt', { session: false });
/**
* Middleware: Mark authenticated reads as public to bypass permission check.
* Constructor page users are authenticated but may not have explicit permission.
*/
const allowAuthenticatedRead = (req, _res, next) => {
if (['GET', 'OPTIONS'].includes(req.method)) {
req.isRuntimePublicRequest = true;
}
return next();
};
/**
* Middleware: Production GET is public, everything else requires JWT.
* Determines public access from URL path, not headers.
*/
const requireProductionOrAuth = (req, res, next) => {
const { environment } = req.params;
const isProduction = environment === 'production';
const isReadOnly = ['GET', 'OPTIONS'].includes(req.method);
if (isProduction && isReadOnly) {
// Public access for production GET
return next();
}
// Require JWT for non-production or write operations
return jwtAuth(req, res, next);
};
// Mark reads as public first, then apply CRUD permission checks
router.use(allowAuthenticatedRead);
router.use(checkCrudPermissions('project_transition_settings'));
/**
* @swagger
* tags:
* name: Project_transition_settings
* description: Environment-aware project transition settings
*/
/**
* @swagger
* /api/project-transition-settings/project/{projectId}/env/{environment}:
* get:
* summary: Get transition settings for a project in a specific environment
* tags: [Project_transition_settings]
* description: Production environment is publicly accessible. Dev/stage require authentication.
* parameters:
* - in: path
* name: projectId
* required: true
* schema:
* type: string
* format: uuid
* - in: path
* name: environment
* required: true
* schema:
* type: string
* enum: [dev, stage, production]
* responses:
* 200:
* description: Transition settings for the project/environment (null if none exist)
* 401:
* description: Authentication required (for dev/stage environments)
*/
router.get(
'/project/:projectId/env/:environment',
requireProductionOrAuth,
wrapAsync(async (req, res) => {
const { projectId, environment } = req.params;
if (!isUuidV4(projectId)) {
return res.status(400).send({ message: 'Invalid project ID' });
}
if (!['dev', 'stage', 'production'].includes(environment)) {
return res.status(400).send({ message: 'Invalid environment' });
}
const settings =
await Project_transition_settingsService.findByProjectAndEnvironment(
projectId,
environment,
req.currentUser,
);
// Return null if no settings exist (frontend will use global defaults)
res.status(200).send(settings);
}),
);
/**
* @swagger
* /api/project-transition-settings/project/{projectId}/env/{environment}:
* put:
* summary: Create or update transition settings for a project in a specific environment
* tags: [Project_transition_settings]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: projectId
* required: true
* schema:
* type: string
* format: uuid
* - in: path
* name: environment
* required: true
* schema:
* type: string
* enum: [dev, stage, production]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* data:
* type: object
* properties:
* transition_type:
* type: string
* enum: [fade, none, video]
* duration_ms:
* type: integer
* minimum: 0
* easing:
* type: string
* enum: [ease-in-out, ease-in, ease-out, linear]
* overlay_color:
* type: string
* responses:
* 200:
* description: Settings created or updated successfully
*/
router.put(
'/project/:projectId/env/:environment',
jwtAuth,
wrapAsync(async (req, res) => {
const { projectId, environment } = req.params;
if (!isUuidV4(projectId)) {
return res.status(400).send({ message: 'Invalid project ID' });
}
if (!['dev', 'stage', 'production'].includes(environment)) {
return res.status(400).send({ message: 'Invalid environment' });
}
const settings = await Project_transition_settingsService.upsertForProject(
projectId,
environment,
req.body.data || {},
req.currentUser,
);
res.status(200).send(settings);
}),
);
/**
* @swagger
* /api/project-transition-settings/project/{projectId}/env/{environment}:
* delete:
* summary: Delete transition settings for a project in a specific environment (reverts to global defaults)
* tags: [Project_transition_settings]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: projectId
* required: true
* schema:
* type: string
* format: uuid
* - in: path
* name: environment
* required: true
* schema:
* type: string
* enum: [dev, stage, production]
* responses:
* 200:
* description: Settings deleted successfully
*/
router.delete(
'/project/:projectId/env/:environment',
jwtAuth,
wrapAsync(async (req, res) => {
const { projectId, environment } = req.params;
if (!isUuidV4(projectId)) {
return res.status(400).send({ message: 'Invalid project ID' });
}
if (!['dev', 'stage', 'production'].includes(environment)) {
return res.status(400).send({ message: 'Invalid environment' });
}
const settings =
await Project_transition_settingsService.findByProjectAndEnvironment(
projectId,
environment,
req.currentUser,
);
if (settings) {
await Project_transition_settingsService.remove(
settings.id,
req.currentUser,
);
}
res.status(200).send({ success: true });
}),
);
/**
* @swagger
* /api/project-transition-settings:
* get:
* summary: Get all project transition settings
* tags: [Project_transition_settings]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: List of project transition settings
*/
router.get(
'/',
jwtAuth,
wrapAsync(async (req, res) => {
const payload = await Project_transition_settingsDBApi.findAll(req.query);
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/project-transition-settings:
* post:
* summary: Create new project transition settings
* tags: [Project_transition_settings]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* data:
* type: object
* responses:
* 200:
* description: Settings created successfully
*/
router.post(
'/',
jwtAuth,
wrapAsync(async (req, res) => {
const payload = await Project_transition_settingsService.create(
req.body.data,
req.currentUser,
);
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/project-transition-settings/{id}:
* get:
* summary: Get project transition settings by ID
* tags: [Project_transition_settings]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* format: uuid
* responses:
* 200:
* description: Project transition settings
*/
router.get(
'/:id',
jwtAuth,
wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) {
return res.status(400).send({ message: 'Invalid ID' });
}
const payload = await Project_transition_settingsDBApi.findBy({
id: req.params.id,
});
res.status(200).send(payload);
}),
);
/**
* @swagger
* /api/project-transition-settings/{id}:
* put:
* summary: Update project transition settings by ID
* tags: [Project_transition_settings]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* format: uuid
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* data:
* type: object
* responses:
* 200:
* description: Settings updated successfully
*/
router.put(
'/:id',
jwtAuth,
wrapAsync(async (req, res) => {
await Project_transition_settingsService.update(
req.body.data,
req.body.id,
req.currentUser,
);
res.status(200).send(true);
}),
);
/**
* @swagger
* /api/project-transition-settings/{id}:
* delete:
* summary: Delete project transition settings by ID
* tags: [Project_transition_settings]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* format: uuid
* responses:
* 200:
* description: Settings deleted successfully
*/
router.delete(
'/:id',
jwtAuth,
wrapAsync(async (req, res) => {
await Project_transition_settingsService.remove(
req.params.id,
req.currentUser,
);
res.status(200).send(true);
}),
);
router.use('/', commonErrorHandler);
module.exports = router;

View File

@ -35,9 +35,20 @@ const getCachePath = (privateUrl) => {
/** /**
* Check if a cached file exists and is still valid * Check if a cached file exists and is still valid
* Returns invalid if a download is in progress (.downloading file exists)
*/ */
const getCachedFile = async (cachePath) => { const getCachedFile = async (cachePath) => {
try { try {
// Check if download is in progress - if so, don't use cache
const downloadingPath = cachePath + '.downloading';
try {
await fs.promises.access(downloadingPath);
// Download in progress, cache is not valid
return { stats: null, valid: false };
} catch {
// No download in progress, continue checking cache
}
const stats = await fs.promises.stat(cachePath); const stats = await fs.promises.stat(cachePath);
const age = (Date.now() - stats.mtimeMs) / 1000; const age = (Date.now() - stats.mtimeMs) / 1000;
if (age < config.s3CacheMaxAge) { if (age < config.s3CacheMaxAge) {
@ -324,6 +335,38 @@ const uploadFile = async (folder, req, res) => {
} }
}; };
/**
* Parse Range header value
* @param {string} rangeHeader - Range header value (e.g., "bytes=0-1000")
* @param {number} totalSize - Total file size
* @returns {{start: number, end: number} | null}
*/
const parseRangeHeader = (rangeHeader, totalSize) => {
if (!rangeHeader || !rangeHeader.startsWith('bytes=')) return null;
const range = rangeHeader.slice(6); // Remove "bytes="
const parts = range.split('-');
let start = parseInt(parts[0], 10);
let end = parts[1] ? parseInt(parts[1], 10) : totalSize - 1;
// Handle suffix ranges (e.g., bytes=-500 means last 500 bytes)
if (isNaN(start)) {
start = totalSize - end;
end = totalSize - 1;
}
// Validate range
if (isNaN(start) || isNaN(end) || start > end || start >= totalSize) {
return null;
}
// Cap end to file size
end = Math.min(end, totalSize - 1);
return { start, end };
};
const downloadFile = async (req, res) => { const downloadFile = async (req, res) => {
const provider = getFileStorageProvider(); const provider = getFileStorageProvider();
const privateUrl = req.query.privateUrl; const privateUrl = req.query.privateUrl;
@ -348,6 +391,7 @@ const downloadFile = async (req, res) => {
} }
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Accept-Ranges', 'bytes');
// Create AbortController for request cancellation // Create AbortController for request cancellation
const abortController = new AbortController(); const abortController = new AbortController();
@ -382,14 +426,6 @@ const downloadFile = async (req, res) => {
return res.status(304).end(); return res.status(304).end();
} }
// Set caching headers
res.setHeader('ETag', etag);
res.setHeader(
'Cache-Control',
`public, max-age=${config.s3CacheMaxAge}`,
);
res.setHeader('Content-Length', stats.size);
// Determine content type from extension // Determine content type from extension
const ext = path.extname(privateUrl).toLowerCase(); const ext = path.extname(privateUrl).toLowerCase();
const mimeTypes = { const mimeTypes = {
@ -410,22 +446,84 @@ const downloadFile = async (req, res) => {
res.setHeader('Content-Type', mimeTypes[ext]); res.setHeader('Content-Type', mimeTypes[ext]);
} }
log.debug( // Handle Range requests for cached files
{ const rangeHeader = req.headers.range;
provider, if (rangeHeader) {
privateUrl, const range = parseRangeHeader(rangeHeader, stats.size);
duration: Date.now() - startTime, if (range) {
cached: true, const { start, end } = range;
}, const chunkSize = end - start + 1;
'File served from cache',
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${stats.size}`);
res.setHeader('Content-Length', chunkSize);
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', `public, max-age=${config.s3CacheMaxAge}`);
return fs.createReadStream(cachePath, { start, end }).pipe(res);
}
// Invalid range - return 416
res.setHeader('Content-Range', `bytes */${stats.size}`);
return res.status(416).end();
}
// Set caching headers for full file
res.setHeader('ETag', etag);
res.setHeader(
'Cache-Control',
`public, max-age=${config.s3CacheMaxAge}`,
); );
res.setHeader('Content-Length', stats.size);
// Stream from cache // Stream from cache
return fs.createReadStream(cachePath).pipe(res); return fs.createReadStream(cachePath).pipe(res);
} }
} }
// Download from S3 // Handle Range requests for S3 (bypass cache for partial requests)
const rangeHeader = req.headers.range;
if (rangeHeader) {
// For Range requests, we need to get file size first via headObject
const headResult = await s3.download(privateUrl, { signal, headOnly: true });
const totalSize = headResult.contentLength;
if (!totalSize) {
log.warn({ privateUrl }, 'Cannot determine file size for range request');
return res.status(500).send(createErrorResponse('Cannot determine file size', 'SIZE_UNKNOWN'));
}
const range = parseRangeHeader(rangeHeader, totalSize);
if (!range) {
res.setHeader('Content-Range', `bytes */${totalSize}`);
return res.status(416).end();
}
const { start, end } = range;
const chunkSize = end - start + 1;
// Download range from S3
const rangeResult = await s3.download(privateUrl, {
signal,
range: `bytes=${start}-${end}`,
});
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${totalSize}`);
res.setHeader('Content-Length', chunkSize);
if (rangeResult.contentType) res.setHeader('Content-Type', rangeResult.contentType);
res.setHeader('Cache-Control', `public, max-age=${config.s3CacheMaxAge}`);
if (typeof rangeResult.body.pipe === 'function') {
return rangeResult.body.pipe(res);
} else if (typeof rangeResult.body.transformToByteArray === 'function') {
const bytes = await rangeResult.body.transformToByteArray();
return res.send(Buffer.from(bytes));
} else {
return res.send(rangeResult.body);
}
}
// Download from S3 (full file)
const result = await s3.download(privateUrl, { signal }); const result = await s3.download(privateUrl, { signal });
if (result.contentType) res.setHeader('Content-Type', result.contentType); if (result.contentType) res.setHeader('Content-Type', result.contentType);
@ -433,15 +531,17 @@ const downloadFile = async (req, res) => {
res.setHeader('Content-Length', result.contentLength); res.setHeader('Content-Length', result.contentLength);
// Add caching headers for browser // Add caching headers for browser
res.setHeader( res.setHeader('Cache-Control', `public, max-age=${config.s3CacheMaxAge}`);
'Cache-Control',
`public, max-age=${config.s3CacheMaxAge}`,
);
if (useCache && typeof result.body.pipe === 'function') { if (useCache && typeof result.body.pipe === 'function') {
// Stream to both response and cache file // Stream to both response and cache file using atomic writes
await ensureCacheDir(); await ensureCacheDir();
const cacheStream = fs.createWriteStream(cachePath); const tempPath = cachePath + '.tmp';
const downloadingPath = cachePath + '.downloading';
// Create marker file to indicate download in progress
await fs.promises.writeFile(downloadingPath, '');
const cacheStream = fs.createWriteStream(tempPath);
// Use pipeline for proper error handling // Use pipeline for proper error handling
const { PassThrough } = require('stream'); const { PassThrough } = require('stream');
@ -451,8 +551,43 @@ const downloadFile = async (req, res) => {
passThrough.pipe(res); passThrough.pipe(res);
passThrough.pipe(cacheStream); passThrough.pipe(cacheStream);
cacheStream.on('error', (err) => { // Track bytes written to verify complete download
let bytesWritten = 0;
passThrough.on('data', (chunk) => {
bytesWritten += chunk.length;
});
cacheStream.on('finish', async () => {
try {
// Verify we got the expected size
const expectedSize = result.contentLength;
if (expectedSize && bytesWritten !== expectedSize) {
log.warn(
{ cachePath, bytesWritten, expectedSize },
'Cache file size mismatch, discarding',
);
await fs.promises.unlink(tempPath).catch(() => {});
} else {
// Atomic rename: temp → final
await fs.promises.rename(tempPath, cachePath);
log.debug(
{ cachePath, bytesWritten },
'Cache file written successfully',
);
}
} catch (err) {
log.warn({ err, cachePath }, 'Failed to finalize cache file');
} finally {
// Remove download marker
await fs.promises.unlink(downloadingPath).catch(() => {});
}
});
cacheStream.on('error', async (err) => {
log.warn({ err, cachePath }, 'Failed to write to cache'); log.warn({ err, cachePath }, 'Failed to write to cache');
// Cleanup temp and marker files
await fs.promises.unlink(tempPath).catch(() => {});
await fs.promises.unlink(downloadingPath).catch(() => {});
}); });
} else if (typeof result.body.pipe === 'function') { } else if (typeof result.body.pipe === 'function') {
result.body.pipe(res); result.body.pipe(res);
@ -460,12 +595,17 @@ const downloadFile = async (req, res) => {
const bytes = await result.body.transformToByteArray(); const bytes = await result.body.transformToByteArray();
const buffer = Buffer.from(bytes); const buffer = Buffer.from(bytes);
// Cache the buffer // Cache the buffer atomically (write to temp, then rename)
if (useCache) { if (useCache) {
await ensureCacheDir(); await ensureCacheDir();
fs.promises.writeFile(cachePath, buffer).catch((err) => { const tempPath = cachePath + '.tmp';
log.warn({ err, cachePath }, 'Failed to write to cache'); fs.promises
}); .writeFile(tempPath, buffer)
.then(() => fs.promises.rename(tempPath, cachePath))
.catch((err) => {
log.warn({ err, cachePath }, 'Failed to write to cache');
fs.promises.unlink(tempPath).catch(() => {});
});
} }
res.send(buffer); res.send(buffer);
@ -494,7 +634,58 @@ const downloadFile = async (req, res) => {
.send(createErrorResponse('File not found', 'NOT_FOUND')); .send(createErrorResponse('File not found', 'NOT_FOUND'));
} }
} else { } else {
res.download(path.join(config.uploadDir, privateUrl)); // Local storage - support Range requests for video streaming
const localFilePath = path.join(config.uploadDir, privateUrl);
if (!fs.existsSync(localFilePath)) {
return res.status(404).send(createErrorResponse('File not found', 'NOT_FOUND'));
}
const stats = fs.statSync(localFilePath);
const totalSize = stats.size;
// Determine content type from extension
const ext = path.extname(privateUrl).toLowerCase();
const mimeTypes = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.pdf': 'application/pdf',
};
if (mimeTypes[ext]) {
res.setHeader('Content-Type', mimeTypes[ext]);
}
// Handle Range requests
const rangeHeader = req.headers.range;
if (rangeHeader) {
const range = parseRangeHeader(rangeHeader, totalSize);
if (range) {
const { start, end } = range;
const chunkSize = end - start + 1;
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${totalSize}`);
res.setHeader('Content-Length', chunkSize);
return fs.createReadStream(localFilePath, { start, end }).pipe(res);
}
// Invalid range - return 416
res.setHeader('Content-Range', `bytes */${totalSize}`);
return res.status(416).end();
}
// Full file download
res.setHeader('Content-Length', totalSize);
return fs.createReadStream(localFilePath).pipe(res);
} }
} catch (error) { } catch (error) {
// Don't log abort errors as they're expected when client disconnects // Don't log abort errors as they're expected when client disconnects
@ -703,11 +894,6 @@ const initUploadSession = async (req, res) => {
contentType, contentType,
}); });
log.info(
{ sessionId, folder, filename, totalChunks, size },
'Upload session initialized',
);
return res.status(200).send({ return res.status(200).send({
sessionId, sessionId,
uploadedChunks: [], uploadedChunks: [],
@ -839,13 +1025,11 @@ const finalizeUploadSession = async (req, res) => {
// Verify all chunks exist // Verify all chunks exist
for (let i = 0; i < session.totalChunks; i++) { for (let i = 0; i < session.totalChunks; i++) {
if (!sessionManager.chunkExists(sessionId, i)) { if (!sessionManager.chunkExists(sessionId, i)) {
return res return res.status(400).send(
.status(400) createErrorResponse(`Missing chunk ${i}`, 'MISSING_CHUNK', {
.send( missingChunk: i,
createErrorResponse(`Missing chunk ${i}`, 'MISSING_CHUNK', { }),
missingChunk: i, );
}),
);
} }
} }
@ -864,6 +1048,7 @@ const finalizeUploadSession = async (req, res) => {
if (provider === 's3') { if (provider === 's3') {
const s3 = getS3Provider(); const s3 = getS3Provider();
const data = fs.readFileSync(assembledPath); const data = fs.readFileSync(assembledPath);
const result = await s3.upload(privateUrl, data, { const result = await s3.upload(privateUrl, data, {
contentType: session.contentType, contentType: session.contentType,
}); });
@ -956,12 +1141,16 @@ const getMimeTypeFromExtension = (filepath) => {
*/ */
const copyFile = async (sourceKey, destKey, options = {}) => { const copyFile = async (sourceKey, destKey, options = {}) => {
const provider = getFileStorageProvider(); const provider = getFileStorageProvider();
const contentType = options.contentType || getMimeTypeFromExtension(sourceKey); const contentType =
options.contentType || getMimeTypeFromExtension(sourceKey);
if (provider === 's3') { if (provider === 's3') {
const s3 = getS3Provider(); const s3 = getS3Provider();
const result = await s3.copy(sourceKey, destKey, { contentType }); const result = await s3.copy(sourceKey, destKey, { contentType });
logger.debug({ sourceKey, destKey, provider: 's3' }, 'File copied (server-side)'); logger.debug(
{ sourceKey, destKey, provider: 's3' },
'File copied (server-side)',
);
return { url: result.url }; return { url: result.url };
} }
@ -969,7 +1158,9 @@ const copyFile = async (sourceKey, destKey, options = {}) => {
const local = getLocalProvider(); const local = getLocalProvider();
await local.copy(sourceKey, destKey); await local.copy(sourceKey, destKey);
logger.debug({ sourceKey, destKey, provider: 'local' }, 'File copied'); logger.debug({ sourceKey, destKey, provider: 'local' }, 'File copied');
return { url: `/api/file/download?privateUrl=${encodeURIComponent(destKey)}` }; return {
url: `/api/file/download?privateUrl=${encodeURIComponent(destKey)}`,
};
} }
// GCloud fallback: download + upload (no native copy implemented) // GCloud fallback: download + upload (no native copy implemented)
@ -1029,13 +1220,20 @@ const copyFilesParallel = async (copies, options = {}) => {
throw new Error(`Copy failed for ${copy.sourceKey}: ${errorMsg}`); throw new Error(`Copy failed for ${copy.sourceKey}: ${errorMsg}`);
} }
logger.warn({ sourceKey: copy.sourceKey, error: errorMsg }, 'File copy failed'); logger.warn(
{ sourceKey: copy.sourceKey, error: errorMsg },
'File copy failed',
);
} }
} }
} }
logger.info( logger.info(
{ succeeded: succeeded.length, failed: failed.length, total: copies.length }, {
succeeded: succeeded.length,
failed: failed.length,
total: copies.length,
},
'Batch file copy completed', 'Batch file copy completed',
); );

View File

@ -227,18 +227,44 @@ class S3StorageProvider extends BaseStorageProvider {
* @param {string} key - Storage key/path * @param {string} key - Storage key/path
* @param {Object} [options] - Download options * @param {Object} [options] - Download options
* @param {AbortSignal} [options.signal] - AbortController signal for cancellation * @param {AbortSignal} [options.signal] - AbortController signal for cancellation
* @param {boolean} [options.headOnly] - Only get metadata (HEAD request)
* @param {string} [options.range] - HTTP Range header value (e.g., "bytes=0-1000")
* @returns {Promise<{ body: ReadableStream, contentType?: string, contentLength?: number }>} * @returns {Promise<{ body: ReadableStream, contentType?: string, contentLength?: number }>}
*/ */
async download(key, options = {}) { async download(key, options = {}) {
const fullKey = this.buildKey(key); const fullKey = this.buildKey(key);
const { signal } = options; const { signal, headOnly, range } = options;
const sendOptions = signal ? { abortSignal: signal } : {}; const sendOptions = signal ? { abortSignal: signal } : {};
// HEAD request for metadata only
if (headOnly) {
const output = await this.client.send(
new HeadObjectCommand({
Bucket: this.bucket,
Key: fullKey,
}),
sendOptions,
);
return {
body: null,
contentType: output.ContentType,
contentLength: output.ContentLength,
};
}
// Build GetObjectCommand with optional Range header
const commandParams = {
Bucket: this.bucket,
Key: fullKey,
};
if (range) {
commandParams.Range = range;
}
const output = await this.client.send( const output = await this.client.send(
new GetObjectCommand({ new GetObjectCommand(commandParams),
Bucket: this.bucket,
Key: fullKey,
}),
sendOptions, sendOptions,
); );

View File

@ -0,0 +1,6 @@
const Global_transition_defaultsDBApi = require('../db/api/global_transition_defaults');
const { createEntityService } = require('../factories/service.factory');
module.exports = createEntityService(Global_transition_defaultsDBApi, {
entityName: 'global_transition_defaults',
});

View File

@ -0,0 +1,162 @@
const db = require('../db/models');
const Project_transition_settingsDBApi = require('../db/api/project_transition_settings');
const processFile = require('../middlewares/upload');
const ValidationError = require('./notifications/errors/validation');
const csv = require('csv-parser');
const stream = require('stream');
module.exports = class Project_transition_settingsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const createdRecord = await Project_transition_settingsDBApi.create(
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return createdRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
await processFile(req, res);
const bufferStream = new stream.PassThrough();
const results = [];
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));
await new Promise((resolve, reject) => {
bufferStream
.pipe(csv())
.on('data', (data) => results.push(data))
.on('end', async () => {
console.log('CSV results', results);
resolve();
})
.on('error', (error) => reject(error));
});
await Project_transition_settingsDBApi.bulkImport(results, {
transaction,
ignoreDuplicates: true,
validate: true,
currentUser: req.currentUser,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async update(data, id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
let record = await Project_transition_settingsDBApi.findBy(
{ id },
{ transaction },
);
if (!record) {
throw new ValidationError('project_transition_settingsNotFound');
}
const updatedRecord = await Project_transition_settingsDBApi.update(
id,
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return updatedRecord;
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Project_transition_settingsDBApi.deleteByIds(ids, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
static async remove(id, currentUser) {
const transaction = await db.sequelize.transaction();
try {
await Project_transition_settingsDBApi.remove(id, {
currentUser,
transaction,
});
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
/**
* Find settings by project ID and environment
*/
static async findByProjectAndEnvironment(
projectId,
environment,
currentUser,
) {
return Project_transition_settingsDBApi.findByProjectAndEnvironment(
projectId,
environment,
{ currentUser },
);
}
/**
* Create or update settings for a project/environment combination
*/
static async upsertForProject(projectId, environment, data, currentUser) {
const transaction = await db.sequelize.transaction();
try {
const result = await Project_transition_settingsDBApi.upsertForProject(
projectId,
environment,
data,
{
currentUser,
transaction,
},
);
await transaction.commit();
return result;
} catch (error) {
await transaction.rollback();
throw error;
}
}
};

View File

@ -293,10 +293,13 @@ class ProjectsService extends BaseProjectsService {
'Starting parallel file copy for project clone', 'Starting parallel file copy for project clone',
); );
const { succeeded, failed } = await FileService.copyFilesParallel(copyOperations, { const { succeeded, failed } = await FileService.copyFilesParallel(
concurrency: 10, copyOperations,
continueOnError: true, {
}); concurrency: 10,
continueOnError: true,
},
);
// ============================================ // ============================================
// Phase D: Build assetPathMap from results // Phase D: Build assetPathMap from results
@ -322,7 +325,9 @@ class ProjectsService extends BaseProjectsService {
asset_type: sourceAsset.asset_type, asset_type: sourceAsset.asset_type,
type: sourceAsset.type || 'general', type: sourceAsset.type || 'general',
cdn_url: '', // Will be populated on first presigned URL request cdn_url: '', // Will be populated on first presigned URL request
storage_key: assetPathMap.get(sourceAsset.storage_key) || sourceAsset.storage_key, storage_key:
assetPathMap.get(sourceAsset.storage_key) ||
sourceAsset.storage_key,
mime_type: sourceAsset.mime_type, mime_type: sourceAsset.mime_type,
size_mb: sourceAsset.size_mb, size_mb: sourceAsset.size_mb,
width_px: sourceAsset.width_px, width_px: sourceAsset.width_px,
@ -345,7 +350,8 @@ class ProjectsService extends BaseProjectsService {
if (sourceVariant.variant_type === 'reversed') continue; // Handled in Phase F if (sourceVariant.variant_type === 'reversed') continue; // Handled in Phase F
const variantStorageKey = const variantStorageKey =
assetPathMap.get(sourceVariant.storage_key) || sourceVariant.storage_key; assetPathMap.get(sourceVariant.storage_key) ||
sourceVariant.storage_key;
await db.asset_variants.create( await db.asset_variants.create(
{ {
@ -385,10 +391,13 @@ class ProjectsService extends BaseProjectsService {
'Copying reversed videos for cloned assets', 'Copying reversed videos for cloned assets',
); );
const reversedResults = await FileService.copyFilesParallel(reversedCopyOps, { const reversedResults = await FileService.copyFilesParallel(
concurrency: 10, reversedCopyOps,
continueOnError: true, // Many assets won't have reversed videos - that's OK {
}); concurrency: 10,
continueOnError: true, // Many assets won't have reversed videos - that's OK
},
);
// Add successful reversed video copies to assetPathMap // Add successful reversed video copies to assetPathMap
for (const { sourceKey, destKey } of reversedResults.succeeded) { for (const { sourceKey, destKey } of reversedResults.succeeded) {
@ -431,15 +440,18 @@ class ProjectsService extends BaseProjectsService {
// Transform background URLs to new storage keys // Transform background URLs to new storage keys
if (pageData.background_image_url) { if (pageData.background_image_url) {
pageData.background_image_url = pageData.background_image_url =
assetPathMap.get(pageData.background_image_url) || pageData.background_image_url; assetPathMap.get(pageData.background_image_url) ||
pageData.background_image_url;
} }
if (pageData.background_video_url) { if (pageData.background_video_url) {
pageData.background_video_url = pageData.background_video_url =
assetPathMap.get(pageData.background_video_url) || pageData.background_video_url; assetPathMap.get(pageData.background_video_url) ||
pageData.background_video_url;
} }
if (pageData.background_audio_url) { if (pageData.background_audio_url) {
pageData.background_audio_url = pageData.background_audio_url =
assetPathMap.get(pageData.background_audio_url) || pageData.background_audio_url; assetPathMap.get(pageData.background_audio_url) ||
pageData.background_audio_url;
} }
await db.tour_pages.create( await db.tour_pages.create(

View File

@ -257,16 +257,21 @@ module.exports = class PublishService {
transaction, transaction,
) { ) {
// Get source content // Get source content
const [sourcePages, sourceAudioTracks] = await Promise.all([ const [sourcePages, sourceAudioTracks, sourceTransitionSettings] =
db.tour_pages.findAll({ await Promise.all([
where: { projectId, environment: fromEnv }, db.tour_pages.findAll({
transaction, where: { projectId, environment: fromEnv },
}), transaction,
db.project_audio_tracks.findAll({ }),
where: { projectId, environment: fromEnv }, db.project_audio_tracks.findAll({
transaction, where: { projectId, environment: fromEnv },
}), transaction,
]); }),
db.project_transition_settings.findOne({
where: { projectId, environment: fromEnv },
transaction,
}),
]);
// Clean up target environment (hard delete - paranoid models need force: true) // Clean up target environment (hard delete - paranoid models need force: true)
await Promise.all([ await Promise.all([
@ -280,6 +285,11 @@ module.exports = class PublishService {
transaction, transaction,
force: true, force: true,
}), }),
db.project_transition_settings.destroy({
where: { projectId, environment: toEnv },
transaction,
force: true,
}),
]); ]);
const actorId = currentUser?.id || null; const actorId = currentUser?.id || null;
@ -325,9 +335,26 @@ module.exports = class PublishService {
}); });
} }
// Create target transition settings (if source exists)
if (sourceTransitionSettings) {
const settingsData = sanitizeRecordForClone(sourceTransitionSettings);
await db.project_transition_settings.create(
{
...settingsData,
projectId,
environment: toEnv,
source_key: sourceTransitionSettings.id,
createdById: actorId,
updatedById: actorId,
},
{ transaction },
);
}
return { return {
pages_copied: sourcePages.length, pages_copied: sourcePages.length,
audios_copied: sourceAudioTracks.length, audios_copied: sourceAudioTracks.length,
transition_settings_copied: sourceTransitionSettings ? 1 : 0,
}; };
} }
}; };

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,90 @@
/**
* CanvasLoadingSpinner Component
*
* Loading spinner overlay for canvas contexts.
* Shows during video preparation/buffering.
* Includes delay to avoid flashing for quick operations.
*/
import React, { useState, useEffect } from 'react';
import { PRELOAD_CONFIG } from '../config/preload.config';
interface CanvasLoadingSpinnerProps {
/** Whether the spinner is visible */
isVisible: boolean;
/** Loading message to display */
message?: string;
/** Spinner size */
size?: 'sm' | 'md' | 'lg';
/** Loading progress (0-100) */
progress?: number;
/** Z-index for stacking (default: 100) */
zIndex?: number;
}
const CanvasLoadingSpinner: React.FC<CanvasLoadingSpinnerProps> = ({
isVisible,
message,
size = 'md',
progress,
zIndex = 100,
}) => {
// Delayed visibility - only show after SPINNER_DELAY_MS
const [showSpinner, setShowSpinner] = useState(false);
useEffect(() => {
if (isVisible) {
// Start timer to show spinner after delay
const timer = setTimeout(() => {
setShowSpinner(true);
}, PRELOAD_CONFIG.ui.spinnerDelayMs);
return () => clearTimeout(timer);
} else {
// Hide immediately when loading completes
setShowSpinner(false);
}
}, [isVisible]);
if (!showSpinner) return null;
const sizeClasses = {
sm: 'w-8 h-8 border-2',
md: 'w-12 h-12 border-[3px]',
lg: 'w-16 h-16 border-4',
};
return (
<div
className='absolute inset-0 flex flex-col items-center justify-center pointer-events-none'
style={{ zIndex }}
>
{/* Spinner with subtle shadow for visibility on any background */}
<div
className='relative'
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.5))' }}
>
{/* Spinner ring */}
<div
className={`${sizeClasses[size]} rounded-full border-white/30 border-t-white animate-spin`}
style={{ borderStyle: 'solid' }}
/>
{/* Progress indicator (optional) */}
{progress !== undefined && (
<div className='absolute inset-0 flex items-center justify-center'>
<span className='text-white text-xs font-medium drop-shadow-md'>
{Math.round(progress)}%
</span>
</div>
)}
</div>
{message && (
<p className='mt-3 text-white/90 text-sm font-medium drop-shadow-md'>
{message}
</p>
)}
</div>
);
};
export default CanvasLoadingSpinner;

View File

@ -58,7 +58,7 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
return ( return (
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
{label} {label}
</label> </label>
<select <select
@ -81,17 +81,17 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
))} ))}
</select> </select>
{durationNote && ( {durationNote && (
<p className='mt-1 text-[11px] text-gray-500'>{durationNote}</p> <p className='mt-1 text-[11px] text-white/60'>{durationNote}</p>
)} )}
{/* Video Playback Settings */} {/* Video Playback Settings */}
{showVideoSettings && ( {showVideoSettings && (
<div className='mt-3 space-y-2 border-t border-gray-200 pt-3'> <div className='mt-3 space-y-2 border-t border-white/20 pt-3'>
<p className='text-[10px] font-semibold uppercase text-gray-500'> <p className='text-[10px] font-semibold uppercase text-white/70'>
Playback Settings Playback Settings
</p> </p>
<label className='flex cursor-pointer items-center gap-2 text-[11px] text-gray-700'> <label className='flex cursor-pointer items-center gap-2 text-[11px] text-white/80'>
<input <input
type='checkbox' type='checkbox'
className='h-3 w-3 rounded border-gray-300' className='h-3 w-3 rounded border-gray-300'
@ -103,7 +103,7 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
Autoplay Autoplay
</label> </label>
<label className='flex cursor-pointer items-center gap-2 text-[11px] text-gray-700'> <label className='flex cursor-pointer items-center gap-2 text-[11px] text-white/80'>
<input <input
type='checkbox' type='checkbox'
className='h-3 w-3 rounded border-gray-300' className='h-3 w-3 rounded border-gray-300'
@ -115,7 +115,7 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
Loop Loop
</label> </label>
<label className='flex cursor-pointer items-center gap-2 text-[11px] text-gray-700'> <label className='flex cursor-pointer items-center gap-2 text-[11px] text-white/80'>
<input <input
type='checkbox' type='checkbox'
className='h-3 w-3 rounded border-gray-300' className='h-3 w-3 rounded border-gray-300'
@ -129,7 +129,7 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
<div className='flex gap-2'> <div className='flex gap-2'>
<div className='flex-1'> <div className='flex-1'>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
Start (sec) Start (sec)
</label> </label>
<input <input
@ -149,7 +149,7 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
/> />
</div> </div>
<div className='flex-1'> <div className='flex-1'>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
End (sec) End (sec)
</label> </label>
<input <input

View File

@ -6,11 +6,28 @@
* Supports custom video playback settings (autoplay, loop, muted, start/end time). * Supports custom video playback settings (autoplay, loop, muted, start/end time).
*/ */
import React, { useRef, useEffect } from 'react'; import React, {
useRef,
useEffect,
useState,
useMemo,
useCallback,
} from 'react';
import NextImage from 'next/image'; import NextImage from 'next/image';
import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback'; import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay'; import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
import { scheduleAfterPaint } from '../../lib/browserUtils'; import { baseURLApi } from '../../config';
/**
* Schedule a callback to run after the next browser paint.
* Uses double rAF pattern: first rAF schedules for next frame,
* second rAF ensures the frame has actually been committed.
*/
const scheduleAfterPaint = (callback: () => void): void => {
requestAnimationFrame(() => {
requestAnimationFrame(callback);
});
};
// Type for requestVideoFrameCallback (Safari 15.4+, Chrome 83+) // Type for requestVideoFrameCallback (Safari 15.4+, Chrome 83+)
// The callback receives (now: DOMHighResTimeStamp, metadata: VideoFrameCallbackMetadata) // The callback receives (now: DOMHighResTimeStamp, metadata: VideoFrameCallbackMetadata)
@ -32,8 +49,9 @@ interface CanvasBackgroundProps {
previousBgVideoUrl?: string; previousBgVideoUrl?: string;
isSwitching?: boolean; isSwitching?: boolean;
isNewBgReady?: boolean; isNewBgReady?: boolean;
isFadingIn?: boolean;
onBackgroundReady?: () => void; onBackgroundReady?: () => void;
/** Callback when video buffer state changes (true = buffering, false = ready) */
onVideoBufferStateChange?: (isBuffering: boolean) => void;
// Video playback settings // Video playback settings
videoAutoplay?: boolean; videoAutoplay?: boolean;
videoLoop?: boolean; videoLoop?: boolean;
@ -42,6 +60,8 @@ interface CanvasBackgroundProps {
videoEndTime?: number | null; videoEndTime?: number | null;
/** Original storage path for video - used for play-once tracking (not the resolved blob URL) */ /** Original storage path for video - used for play-once tracking (not the resolved blob URL) */
videoStoragePath?: string; videoStoragePath?: string;
/** Pause video playback (e.g., during navigation to show frozen frame) */
pauseVideo?: boolean;
} }
const CanvasBackground: React.FC<CanvasBackgroundProps> = ({ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
@ -52,49 +72,258 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
previousBgVideoUrl, previousBgVideoUrl,
isSwitching = false, isSwitching = false,
isNewBgReady = false, isNewBgReady = false,
isFadingIn = false,
onBackgroundReady, onBackgroundReady,
onVideoBufferStateChange,
videoAutoplay = true, videoAutoplay = true,
videoLoop = true, videoLoop = true,
videoMuted = true, videoMuted = true,
videoStartTime = null, videoStartTime = null,
videoEndTime = null, videoEndTime = null,
videoStoragePath, videoStoragePath,
pauseVideo = false,
}) => { }) => {
// During page switching with video paused, keep showing the previous video URL.
// This prevents black flash when the video element would remount with a new URL.
// The old video element stays visible (paused at frozen frame) until new page is ready.
const activeVideoUrl =
isSwitching && !isNewBgReady && pauseVideo && previousBgVideoUrl
? previousBgVideoUrl
: backgroundVideoUrl;
// Use background video playback hook for custom start/end time handling // Use background video playback hook for custom start/end time handling
// Use storagePath for play-once tracking (falls back to videoUrl if not provided) // Use storagePath for play-once tracking (falls back to videoUrl if not provided)
// Pass pauseVideo to hook for centralized playback control (fixes video playing during navigation)
const { videoRef, shouldBlockAutoplay } = useBackgroundVideoPlayback({ const { videoRef, shouldBlockAutoplay } = useBackgroundVideoPlayback({
videoUrl: backgroundVideoUrl, videoUrl: activeVideoUrl,
videoStoragePath: videoStoragePath || backgroundVideoUrl, videoStoragePath: videoStoragePath || backgroundVideoUrl,
autoplay: videoAutoplay, autoplay: videoAutoplay,
loop: videoLoop, loop: videoLoop,
muted: videoMuted, muted: videoMuted,
startTime: videoStartTime, startTime: videoStartTime,
endTime: videoEndTime, endTime: videoEndTime,
paused: pauseVideo,
}); });
// Block autoplay if video already played this session (when loop=false) // Block autoplay if: video already played this session OR externally paused
const effectiveAutoplay = videoAutoplay && !shouldBlockAutoplay; const effectiveAutoplay =
videoAutoplay && !shouldBlockAutoplay && !pauseVideo;
const handleLoad = () => { // Video error state for fallback to proxy URL
const [videoError, setVideoError] = useState(false);
// Video buffering state for loading indicator
const [isVideoBuffering, setIsVideoBuffering] = useState(true);
// Track video buffering via canplay/waiting events
useEffect(() => {
const video = videoRef.current;
if (!backgroundVideoUrl || !video) {
setIsVideoBuffering(false);
// CRITICAL: Also notify parent that buffering is done when there's no video
// Without this, parent's isBackgroundVideoBuffering stays stuck at true from previous page
onVideoBufferStateChange?.(false);
return;
}
// Start as buffering for new video
setIsVideoBuffering(true);
onVideoBufferStateChange?.(true);
const handleCanPlay = () => {
setIsVideoBuffering(false);
onVideoBufferStateChange?.(false);
};
const handleWaiting = () => {
setIsVideoBuffering(true);
onVideoBufferStateChange?.(true);
};
video.addEventListener('canplay', handleCanPlay);
video.addEventListener('waiting', handleWaiting);
return () => {
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('waiting', handleWaiting);
};
}, [backgroundVideoUrl, onVideoBufferStateChange]);
// Fallback to proxy URL if presigned URL fails (e.g., CORS, expiration)
const videoSrc = useMemo(() => {
if (!activeVideoUrl) return undefined;
if (videoError && videoStoragePath) {
// Fallback to backend proxy (bypasses CORS issues)
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(videoStoragePath)}`;
}
return activeVideoUrl;
}, [activeVideoUrl, videoStoragePath, videoError]);
// Reset error state when video URL changes
useEffect(() => {
setVideoError(false);
}, [backgroundVideoUrl]);
const handleVideoError = useCallback(() => {
if (!videoError && videoStoragePath) {
// eslint-disable-next-line no-console
console.warn('[CanvasBackground] Video error, falling back to proxy URL');
setVideoError(true);
}
}, [videoError, videoStoragePath]);
// Track if we've already called onBackgroundReady to avoid double calls
const didReportImageReadyRef = useRef(false);
const imageRef = useRef<HTMLImageElement>(null);
// Ref for NextImage wrapper to detect its internal img element
const nextImageWrapperRef = useRef<HTMLDivElement>(null);
// Track previous URL to detect changes synchronously during render
const prevImageUrlRef = useRef<string | undefined>(undefined);
// Track previous switching state to detect navigation start
const prevIsSwitchingRef = useRef(false);
// CRITICAL: Reset ready flag SYNCHRONOUSLY during render, before onLoad can fire.
// Reset when:
// 1. URL changes - new image needs to report ready
// 2. isSwitching transitions from false to true - navigation started, even if URL is the same
// (handles case where two pages have the same background image)
//
// Using useEffect for this creates a race condition:
// 1. URL changes, component re-renders
// 2. For cached images, onLoad fires immediately (maybe even before React attaches handlers)
// 3. handleLoad checks didReportImageReadyRef which is still TRUE from previous image
// 4. Guard exits early, callback is skipped
// 5. useEffect runs AFTER render, resetting the flag too late
// By resetting synchronously here, we ensure the flag is false before any event handlers run.
const switchingStarted = isSwitching && !prevIsSwitchingRef.current;
if (prevImageUrlRef.current !== backgroundImageUrl || switchingStarted) {
didReportImageReadyRef.current = false;
prevImageUrlRef.current = backgroundImageUrl;
}
prevIsSwitchingRef.current = isSwitching;
const handleLoad = useCallback(() => {
if (didReportImageReadyRef.current) {
return;
}
didReportImageReadyRef.current = true;
// Wait for paint to ensure background is actually rendered before reporting ready. // Wait for paint to ensure background is actually rendered before reporting ready.
// This prevents the transition overlay from being removed before the background is visible. // This prevents the transition overlay from being removed before the background is visible.
scheduleAfterPaint(() => { scheduleAfterPaint(() => {
onBackgroundReady?.(); onBackgroundReady?.();
}); });
}; }, [onBackgroundReady, backgroundImageUrl]);
const handleError = () => { const handleError = useCallback(() => {
if (didReportImageReadyRef.current) return;
didReportImageReadyRef.current = true;
onBackgroundReady?.(); onBackgroundReady?.();
}; }, [onBackgroundReady]);
// Track if we've already called onBackgroundReady to avoid double calls // Handle already-loaded images (blob URLs from preload cache)
const didReportReadyRef = useRef(false); // The onLoad event may not fire for images that are already in memory
// Reset ready flag when video URL changes
useEffect(() => { useEffect(() => {
const img = imageRef.current;
if (!backgroundImageUrl || !img || didReportImageReadyRef.current) return;
// Check if image is already loaded (common with blob URLs)
if (img.complete && img.naturalWidth > 0) {
// Use decode() to ensure image is fully decoded before reporting ready
if (typeof img.decode === 'function') {
img.decode().then(handleLoad).catch(handleLoad);
} else {
handleLoad();
}
}
}, [backgroundImageUrl, handleLoad]);
// Handle NextImage load detection (for non-blob URLs like presigned URLs)
// NextImage's onLoad may not fire for cached images, so we detect its internal img element
useEffect(() => {
// Only handle non-blob URLs (blob URLs use native img with imageRef)
if (
!backgroundImageUrl ||
backgroundImageUrl.startsWith('blob:') ||
didReportImageReadyRef.current
)
return;
const wrapper = nextImageWrapperRef.current;
if (!wrapper) return;
let loadCleanup: (() => void) | null = null;
// Setup load listener on the internal img element
const setupLoadListener = (img: HTMLImageElement) => {
// Use decode() to ensure image is fully decoded before reporting ready
// This prevents flash on first load when image needs to be fetched and decoded
const decodeAndReport = () => {
if (typeof img.decode === 'function') {
img.decode().then(handleLoad).catch(handleLoad);
} else {
handleLoad();
}
};
// If already loaded, decode and report
if (img.complete && img.naturalWidth > 0) {
decodeAndReport();
return;
}
// Not loaded yet, attach load event listener
const onLoad = () => decodeAndReport();
img.addEventListener('load', onLoad, { once: true });
loadCleanup = () => img.removeEventListener('load', onLoad);
};
// Check if NextImage's internal img element already exists
const existingImg = wrapper.querySelector('img');
if (existingImg) {
setupLoadListener(existingImg);
return () => loadCleanup?.();
}
// Wait for NextImage to render its internal img element using MutationObserver
const observer = new MutationObserver((mutations) => {
for (let i = 0; i < mutations.length; i++) {
const addedNodes = mutations[i].addedNodes;
for (let j = 0; j < addedNodes.length; j++) {
const node = addedNodes[j];
if (node instanceof HTMLImageElement) {
setupLoadListener(node);
observer.disconnect();
return;
}
if (node instanceof Element) {
const img = node.querySelector('img');
if (img) {
setupLoadListener(img);
observer.disconnect();
return;
}
}
}
}
});
observer.observe(wrapper, { childList: true, subtree: true });
return () => {
observer.disconnect();
loadCleanup?.();
};
}, [backgroundImageUrl, handleLoad]);
// Track if we've already called onBackgroundReady to avoid double calls (for video)
const didReportReadyRef = useRef(false);
// Track previous video URL to detect changes synchronously during render
const prevVideoUrlRef = useRef<string | undefined>(undefined);
// CRITICAL: Reset ready flag SYNCHRONOUSLY during render (same reason as image above).
// Also reset when switching starts, to handle pages with same video URL.
if (prevVideoUrlRef.current !== backgroundVideoUrl || switchingStarted) {
didReportReadyRef.current = false; didReportReadyRef.current = false;
}, [backgroundVideoUrl]); prevVideoUrlRef.current = backgroundVideoUrl;
}
// Handle video first frame ready using requestVideoFrameCallback // Handle video first frame ready using requestVideoFrameCallback
// This ensures the video's first frame is actually painted before we report ready // This ensures the video's first frame is actually painted before we report ready
@ -108,15 +337,31 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
onBackgroundReady?.(); onBackgroundReady?.();
}; };
// Timeout fallback - report ready after 5 seconds even if video hasn't started
// Prevents infinite loading on slow networks or video initialization failures
const timeout = setTimeout(() => {
// eslint-disable-next-line no-console
console.warn(
'[CanvasBackground] Video ready timeout, reporting ready anyway',
);
reportVideoReady();
}, 5000);
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+, Chrome 83+) // Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+, Chrome 83+)
// RVFC fires when frame is decoded, but compositor may not have painted yet.
// Wrap in scheduleAfterPaint for consistency with image handling.
const videoWithRVFC = video as HTMLVideoElementWithRVFC; const videoWithRVFC = video as HTMLVideoElementWithRVFC;
if (typeof videoWithRVFC.requestVideoFrameCallback === 'function') { if (typeof videoWithRVFC.requestVideoFrameCallback === 'function') {
videoWithRVFC.requestVideoFrameCallback(() => { videoWithRVFC.requestVideoFrameCallback(() => {
reportVideoReady(); clearTimeout(timeout);
scheduleAfterPaint(() => {
reportVideoReady();
});
}); });
} else { } else {
// Fallback: use playing event + scheduleAfterPaint // Fallback: use playing event + scheduleAfterPaint
const onPlaying = () => { const onPlaying = () => {
clearTimeout(timeout);
scheduleAfterPaint(() => { scheduleAfterPaint(() => {
reportVideoReady(); reportVideoReady();
}); });
@ -124,22 +369,39 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
video.addEventListener('playing', onPlaying, { once: true }); video.addEventListener('playing', onPlaying, { once: true });
return () => { return () => {
clearTimeout(timeout);
video.removeEventListener('playing', onPlaying); video.removeEventListener('playing', onPlaying);
}; };
} }
return () => clearTimeout(timeout);
}, [backgroundVideoUrl, onBackgroundReady]); }, [backgroundVideoUrl, onBackgroundReady]);
// When endTime is set, we disable native loop and handle it via the hook // When endTime is set, we disable native loop and handle it via the hook
const useNativeLoop = videoEndTime == null ? videoLoop : false; const useNativeLoop = videoEndTime == null ? videoLoop : false;
// Note: pauseVideo is now handled by useBackgroundVideoPlayback hook directly.
// The hook centralizes all playback control, eliminating race conditions between
// separate effects competing to control the video element.
return ( return (
<> <>
{/* Background image - z-1 keeps it below backdrop blur layer (z-5) */} {/* Background image - z-1 keeps it below backdrop blur layer (z-5).
Image layer stays visible while video buffers (fallback behavior).
When video is ready, image fades out via opacity transition. */}
{backgroundImageUrl && ( {backgroundImageUrl && (
<div className='pointer-events-none absolute inset-0 z-1 h-full w-full select-none'> <div
className='pointer-events-none absolute inset-0 z-1 h-full w-full select-none'
style={{
// When video exists and is ready, hide image layer
opacity: backgroundVideoUrl && !isVideoBuffering ? 0 : 1,
transition: 'opacity 300ms ease-out',
}}
>
{backgroundImageUrl.startsWith('blob:') ? ( {backgroundImageUrl.startsWith('blob:') ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
ref={imageRef}
key={`bg_image_${backgroundImageUrl}`} key={`bg_image_${backgroundImageUrl}`}
src={backgroundImageUrl} src={backgroundImageUrl}
alt='Background' alt='Background'
@ -149,49 +411,57 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
onError={handleError} onError={handleError}
/> />
) : ( ) : (
<NextImage <div
key={`bg_image_${backgroundImageUrl}`} ref={nextImageWrapperRef}
src={backgroundImageUrl} className='absolute inset-0 h-full w-full'
alt='Background' >
fill <NextImage
sizes='100vw' key={`bg_image_${backgroundImageUrl}`}
className='object-contain' src={backgroundImageUrl}
draggable={false} alt='Background'
unoptimized fill
onLoad={handleLoad} sizes='100vw'
onError={handleError} className='object-contain'
/> draggable={false}
unoptimized
onLoad={handleLoad}
onError={handleError}
/>
</div>
)} )}
</div> </div>
)} )}
{/* Previous background overlays - show during loading AND crossfade. {/* Previous background overlay - shows during loading (z-2) above new background (z-1).
Uses CSS animation for fade-out effect during crossfade. Black overlay for fade effect is rendered separately at higher z-index (TransitionBlackOverlay). */}
z-0 keeps them BELOW new backgrounds (z-1). */}
<PreviousBackgroundOverlay <PreviousBackgroundOverlay
imageUrl={previousBgImageUrl} imageUrl={previousBgImageUrl}
videoUrl={previousBgVideoUrl} videoUrl={previousBgVideoUrl}
isSwitching={isSwitching} isSwitching={isSwitching}
isNewBgReady={isNewBgReady} isNewBgReady={isNewBgReady}
isFadingIn={isFadingIn} paused={pauseVideo}
/> />
{/* Background video - z-1 keeps it below backdrop blur layer (z-5) {/* Background video - z-1 keeps it below backdrop blur layer (z-5)
Note: muted attribute is always true for iOS autoplay compatibility. Note: muted attribute is always true for iOS autoplay compatibility.
Actual muted state is controlled via useBackgroundVideoPlayback hook Actual muted state is controlled via useBackgroundVideoPlayback hook
which sets video.muted property via JavaScript (useEffect). which sets video.muted property via JavaScript (useEffect).
webkit-playsinline is legacy attribute for older iOS versions. */} webkit-playsinline is legacy attribute for older iOS versions.
{backgroundVideoUrl && ( preload="metadata" is required for iOS Safari video initialization.
Video fades in when ready (opacity transition from 0 to 1). */}
{activeVideoUrl && (
<video <video
ref={videoRef} ref={videoRef}
key={`bg_video_${backgroundVideoUrl}`} key={`bg_video_${activeVideoUrl}`}
className='absolute inset-0 z-1 h-full w-full object-contain' className='absolute inset-0 z-1 h-full w-full object-contain'
src={backgroundVideoUrl} src={videoSrc}
preload='auto'
autoPlay={effectiveAutoplay} autoPlay={effectiveAutoplay}
loop={useNativeLoop} loop={useNativeLoop}
muted={videoMuted} muted={videoMuted}
playsInline playsInline
webkit-playsinline='' webkit-playsinline=''
onError={handleVideoError}
/> />
)} )}

View File

@ -17,12 +17,13 @@ import {
type ElementEffectProperties, type ElementEffectProperties,
} from '../../lib/elementEffects'; } from '../../lib/elementEffects';
import type { CanvasElement as CanvasElementType } from '../../types/constructor'; import type { CanvasElement as CanvasElementType } from '../../types/constructor';
import type { ResolvedTransitionSettings } from '../../types/transition';
import type { PreloadCacheProvider } from '../../hooks/video';
interface CanvasElementProps { interface CanvasElementProps {
element: CanvasElementType; element: CanvasElementType;
isSelected: boolean; isSelected: boolean;
isEditMode: boolean; isEditMode: boolean;
isDisabled?: boolean;
onClick: () => void; onClick: () => void;
onMouseDown?: (event: React.MouseEvent) => void; onMouseDown?: (event: React.MouseEvent) => void;
/** Optional URL resolver for preloaded blob URLs */ /** Optional URL resolver for preloaded blob URLs */
@ -37,19 +38,24 @@ interface CanvasElementProps {
) => void; ) => void;
/** Letterbox styles for constraining fullscreen elements to canvas bounds */ /** Letterbox styles for constraining fullscreen elements to canvas bounds */
letterboxStyles?: React.CSSProperties; letterboxStyles?: React.CSSProperties;
/** Page transition settings (for slide transition cascade in carousel/gallery) */
pageTransitionSettings?: ResolvedTransitionSettings;
/** Preload cache provider for video elements */
preloadCache?: PreloadCacheProvider;
} }
const CanvasElement: React.FC<CanvasElementProps> = ({ const CanvasElement: React.FC<CanvasElementProps> = ({
element, element,
isSelected, isSelected,
isEditMode, isEditMode,
isDisabled = false,
onClick, onClick,
onMouseDown, onMouseDown,
resolveUrl, resolveUrl,
onGalleryCardClick, onGalleryCardClick,
onCarouselButtonPositionChange, onCarouselButtonPositionChange,
letterboxStyles, letterboxStyles,
pageTransitionSettings,
preloadCache,
}) => { }) => {
// Extract effect properties from element // Extract effect properties from element
const effectProperties: Partial<ElementEffectProperties> = { const effectProperties: Partial<ElementEffectProperties> = {
@ -87,11 +93,6 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
left: `${xClamped}%`, left: `${xClamped}%`,
top: `${yClamped}%`, top: `${yClamped}%`,
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
// Reset button defaults to let UiElementRenderer control styling
background: 'transparent',
border: 'none',
padding: 0,
margin: 0,
}; };
// Merge interactive effects (preview mode only) // Merge interactive effects (preview mode only)
@ -121,14 +122,24 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
}; };
} }
// Handle keyboard interaction for accessibility
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick();
}
};
return ( return (
<button <div
type='button' role='button'
tabIndex={0}
data-constructor-element-id={element.id} data-constructor-element-id={element.id}
className='absolute' className='absolute cursor-pointer'
style={positionStyle} style={positionStyle}
onMouseDown={isEditMode ? onMouseDown : undefined} onMouseDown={isEditMode ? onMouseDown : undefined}
onClick={onClick} onClick={onClick}
onKeyDown={handleKeyDown}
{...(!isEditMode ? eventHandlers : {})} {...(!isEditMode ? eventHandlers : {})}
> >
<UiElementRenderer <UiElementRenderer
@ -136,12 +147,13 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
resolveUrl={resolveUrl} resolveUrl={resolveUrl}
isSelected={isSelected} isSelected={isSelected}
isEditMode={isEditMode} isEditMode={isEditMode}
isDisabled={isDisabled}
onGalleryCardClick={onGalleryCardClick} onGalleryCardClick={onGalleryCardClick}
onCarouselButtonPositionChange={onCarouselButtonPositionChange} onCarouselButtonPositionChange={onCarouselButtonPositionChange}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/> />
</button> </div>
); );
}; };

View File

@ -1,77 +0,0 @@
/**
* ConstructorControlsPanel Component
*
* Draggable panel with page selector, mode toggle, and exit button.
* Used in constructor for top-level navigation controls.
*/
import React from 'react';
import BaseButton from '../BaseButton';
import { mdiExitToApp } from '@mdi/js';
import PageSelector from './PageSelector';
import InteractionModeToggle from './InteractionModeToggle';
import type { Position, TourPage, ConstructorInteractionMode } from './types';
interface ConstructorControlsPanelProps {
projectId: string;
pages: TourPage[];
activePageId: string;
interactionMode: ConstructorInteractionMode;
position: Position;
onPageChange: (pageId: string) => void;
onModeChange: (mode: ConstructorInteractionMode) => void;
onDragStart: (event: React.MouseEvent) => void;
}
const ConstructorControlsPanel: React.FC<ConstructorControlsPanelProps> = ({
projectId,
pages,
activePageId,
interactionMode,
position,
onPageChange,
onModeChange,
onDragStart,
}) => {
return (
<div
className='fixed z-[1000] w-[min(92vw,460px)] rounded-lg border border-gray-200 bg-white shadow-xl'
style={{
left: position.x,
top: position.y,
}}
>
<div
className='flex cursor-move items-center justify-between rounded-t-lg border-b border-gray-200 bg-gray-50 px-3 py-2'
onMouseDown={onDragStart}
>
<span className='text-xs font-bold uppercase'>
Constructor Controls
</span>
</div>
<div className='space-y-2 p-3'>
<div className='flex flex-wrap items-center gap-2'>
<PageSelector
pages={pages}
activePageId={activePageId}
onPageChange={onPageChange}
/>
<BaseButton
color='lightDark'
label='Exit to Assets'
icon={mdiExitToApp}
href={
projectId ? `/projects/${projectId}` : '/projects/projects-list'
}
/>
</div>
<InteractionModeToggle
mode={interactionMode}
onModeChange={onModeChange}
/>
</div>
</div>
);
};
export default ConstructorControlsPanel;

View File

@ -1,195 +0,0 @@
/**
* ConstructorMenu Component
*
* Draggable menu panel with actions for adding elements, backgrounds, etc.
*/
import React, { forwardRef } from 'react';
import BaseIcon from '../BaseIcon';
import BaseButton from '../BaseButton';
import {
mdiMenu,
mdiImageMultiple,
mdiViewCarousel,
mdiTooltipText,
mdiSwapHorizontal,
mdiText,
mdiPlus,
mdiExitToApp,
} from '@mdi/js';
import MenuActionButton from './MenuActionButton';
import dataFormatter from '../../helpers/dataFormatter';
import type {
Position,
CanvasElementType,
NavigationElementType,
} from './types';
import type { EditorMenuItem } from '../../types/constructor';
interface ConstructorMenuProps {
position: Position;
isOpen: boolean;
allowedNavigationTypes: NavigationElementType[];
isCreatingPage: boolean;
isSaving: boolean;
isSavingToStage: boolean;
onDragStart: (event: React.MouseEvent) => void;
onToggleOpen: () => void;
onSelectMenuItem: (item: EditorMenuItem) => void;
onAddElement: (type: CanvasElementType) => void;
onCreatePage: () => void;
onSave: () => void;
onSaveToStage: () => void;
onExit: () => void;
/** Page's last saved timestamp (updatedAt from tour_pages) */
lastSavedAt?: string | null;
/** Last save-to-stage timestamp */
lastSavedToStageAt?: string | null;
}
const ConstructorMenu = forwardRef<HTMLDivElement, ConstructorMenuProps>(
(
{
position,
isOpen,
allowedNavigationTypes,
isCreatingPage,
isSaving,
isSavingToStage,
onDragStart,
onToggleOpen,
onSelectMenuItem,
onAddElement,
onCreatePage,
onSave,
onSaveToStage,
onExit,
lastSavedAt,
lastSavedToStageAt,
},
ref,
) => {
return (
<div
ref={ref}
className='fixed z-[1000] w-60 border border-gray-200 rounded-lg bg-white shadow-xl'
style={{ left: position.x, top: position.y }}
>
<div
className='flex items-center justify-between px-3 py-2 border-b border-gray-200 cursor-move bg-gray-50 rounded-t-lg'
onMouseDown={onDragStart}
>
<span className='text-xs font-bold uppercase'>Constructor Menu</span>
<button type='button' onClick={onToggleOpen}>
<BaseIcon path={mdiMenu} size={18} />
</button>
</div>
{isOpen && (
<div className='p-2 space-y-1 max-h-[calc(100vh-120px)] overflow-y-auto'>
<MenuActionButton
icon={mdiImageMultiple}
label='Background Image'
onClick={() => onSelectMenuItem('background_image')}
/>
<MenuActionButton
icon={mdiViewCarousel}
label='Background Video'
onClick={() => onSelectMenuItem('background_video')}
/>
<MenuActionButton
icon={mdiTooltipText}
label='Background Audio'
onClick={() => onSelectMenuItem('background_audio')}
/>
<MenuActionButton
icon={mdiSwapHorizontal}
label='Add Navigation Button'
onClick={() => onAddElement(allowedNavigationTypes[0])}
/>
<MenuActionButton
icon={mdiSwapHorizontal}
label='Add Transition'
onClick={() => onSelectMenuItem('create_transition')}
/>
<MenuActionButton
icon={mdiImageMultiple}
label='Add Gallery'
onClick={() => onAddElement('gallery')}
/>
<MenuActionButton
icon={mdiViewCarousel}
label='Add Carousel'
onClick={() => onAddElement('carousel')}
/>
<MenuActionButton
icon={mdiTooltipText}
label='Add Tooltip'
onClick={() => onAddElement('tooltip')}
/>
<MenuActionButton
icon={mdiText}
label='Add Description'
onClick={() => onAddElement('description')}
/>
<MenuActionButton
icon={mdiViewCarousel}
label='Add Video Player'
onClick={() => onAddElement('video_player')}
/>
<MenuActionButton
icon={mdiTooltipText}
label='Add Audio Player'
onClick={() => onAddElement('audio_player')}
/>
<MenuActionButton
icon={mdiPlus}
label={isCreatingPage ? 'Creating Page...' : 'Create New Page'}
onClick={onCreatePage}
disabled={isCreatingPage}
/>
<div className='pt-2 border-t border-gray-200 space-y-1'>
<BaseButton
small
color='info'
label={isSaving ? 'Saving...' : 'Save'}
subtitle={
lastSavedAt
? dataFormatter.relativeTimestamp(lastSavedAt)
: undefined
}
onClick={onSave}
disabled={isSaving}
className='w-full'
/>
<BaseButton
small
color='success'
label={isSavingToStage ? 'Saving...' : 'Save to Stage'}
subtitle={
lastSavedToStageAt
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
: undefined
}
onClick={onSaveToStage}
disabled={isSavingToStage}
className='w-full'
/>
<MenuActionButton
icon={mdiExitToApp}
label='Exit'
onClick={onExit}
className='!text-red-700'
/>
</div>
</div>
)}
</div>
);
},
);
ConstructorMenu.displayName = 'ConstructorMenu';
export default ConstructorMenu;

View File

@ -0,0 +1,352 @@
/**
* ConstructorToolbar Component
*
* Unified toolbar combining page controls and element actions.
* Glassmorphism styling with draggable positioning.
*/
import React, { useState, useRef, useEffect, forwardRef } from 'react';
import {
mdiDotsVertical,
mdiChevronDown,
mdiImageMultiple,
mdiViewCarousel,
mdiTooltipText,
mdiSwapHorizontal,
mdiText,
mdiPlus,
mdiExitToApp,
mdiChevronLeft,
mdiChevronRight,
mdiMusicNote,
mdiVideo,
} from '@mdi/js';
import BaseIcon from '../BaseIcon';
import BaseButton from '../BaseButton';
import ClickOutside from '../ClickOutside';
import PageSelector from './PageSelector';
import InteractionModeToggle from './InteractionModeToggle';
import MenuActionButton from './MenuActionButton';
import dataFormatter from '../../helpers/dataFormatter';
import type { ConstructorToolbarProps } from './types';
const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
(
{
position,
onDragStart,
pages,
activePageId,
onPageChange,
interactionMode,
onModeChange,
onSelectMenuItem,
allowedNavigationTypes,
onAddElement,
onCreatePage,
isCreatingPage,
onSave,
onSaveToStage,
isSaving,
isSavingToStage,
lastSavedAt,
lastSavedToStageAt,
onExit,
},
ref,
) => {
// Local UI state
const [isCollapsed, setIsCollapsed] = useState(false);
const [activeDropdown, setActiveDropdown] = useState<
'bg' | 'elements' | null
>(null);
// Refs for ClickOutside exclusion (following NavBarItem pattern)
const bgTriggerRef = useRef<HTMLButtonElement>(null);
const elementsTriggerRef = useRef<HTMLButtonElement>(null);
// Keyboard handling (Escape closes dropdown)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && activeDropdown) {
setActiveDropdown(null);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [activeDropdown]);
// Dropdown handlers
const closeDropdown = () => setActiveDropdown(null);
const toggleDropdown = (dropdown: 'bg' | 'elements') => {
setActiveDropdown((prev) => (prev === dropdown ? null : dropdown));
};
// Close dropdown after menu action
const handleMenuAction = (action: () => void) => {
action();
closeDropdown();
};
// Shared button styles
const triggerBtnClass =
'flex items-center gap-1.5 px-3 py-2 rounded text-sm font-medium text-white/90 hover:bg-white/20 transition-colors';
const dropdownPanelClass =
'absolute top-full left-0 mt-1 min-w-[180px] py-1 rounded-lg bg-white/50 backdrop-blur-xl border border-white/30 shadow-lg z-10';
// Collapsed state
if (isCollapsed) {
return (
<div
ref={ref}
className='fixed z-[1000] flex items-center gap-1 px-2 py-1.5 rounded-lg bg-white/10 backdrop-blur-xl border border-white/30 shadow-xl'
style={{ left: position.x, top: position.y }}
>
<div
className='cursor-move flex items-center justify-center w-10 h-10 text-white/60'
onMouseDown={onDragStart}
>
<BaseIcon path={mdiDotsVertical} size={24} />
</div>
<button
type='button'
onClick={() => setIsCollapsed(false)}
className='flex items-center justify-center w-10 h-10 rounded text-white/60 hover:text-white/90 hover:bg-white/20 transition-colors'
title='Expand toolbar'
>
<BaseIcon path={mdiChevronRight} size={26} />
</button>
<span className='text-base font-medium text-white/80 truncate max-w-[140px] pr-2'>
{pages.find((p) => p.id === activePageId)?.name || 'Page'}
</span>
</div>
);
}
return (
<div
ref={ref}
className='fixed z-[1000] flex items-center gap-2 px-2 py-1.5 rounded-lg bg-white/10 backdrop-blur-xl border border-white/30 shadow-xl max-w-[95vw]'
style={{ left: position.x, top: position.y }}
>
{/* Drag Handle */}
<div
className='cursor-move flex items-center justify-center w-10 h-10 text-white/60 hover:text-white/90'
onMouseDown={onDragStart}
>
<BaseIcon path={mdiDotsVertical} size={24} />
</div>
{/* Page Selector - reuse existing component */}
<PageSelector
pages={pages}
activePageId={activePageId}
onPageChange={onPageChange}
/>
{/* Mode Toggle - reuse with compact=true */}
<InteractionModeToggle
mode={interactionMode}
onModeChange={onModeChange}
compact
/>
{/* Divider */}
<div className='w-px h-8 bg-white/30' />
{/* Backgrounds Dropdown */}
<div className='relative'>
<button
ref={bgTriggerRef}
type='button'
onClick={() => toggleDropdown('bg')}
className={triggerBtnClass}
>
<BaseIcon path={mdiImageMultiple} size={18} />
<span>BG</span>
<BaseIcon path={mdiChevronDown} size={16} />
</button>
{activeDropdown === 'bg' && (
<ClickOutside
onClickOutside={closeDropdown}
excludedElements={[bgTriggerRef]}
>
<div className={dropdownPanelClass}>
<MenuActionButton
icon={mdiImageMultiple}
label='Background Image'
onClick={() =>
handleMenuAction(() => onSelectMenuItem('background_image'))
}
/>
<MenuActionButton
icon={mdiVideo}
label='Background Video'
onClick={() =>
handleMenuAction(() => onSelectMenuItem('background_video'))
}
/>
<MenuActionButton
icon={mdiMusicNote}
label='Background Audio'
onClick={() =>
handleMenuAction(() => onSelectMenuItem('background_audio'))
}
/>
</div>
</ClickOutside>
)}
</div>
{/* Elements Dropdown */}
<div className='relative'>
<button
ref={elementsTriggerRef}
type='button'
onClick={() => toggleDropdown('elements')}
className={triggerBtnClass}
>
<BaseIcon path={mdiPlus} size={18} />
<span>Elements</span>
<BaseIcon path={mdiChevronDown} size={16} />
</button>
{activeDropdown === 'elements' && (
<ClickOutside
onClickOutside={closeDropdown}
excludedElements={[elementsTriggerRef]}
>
<div className={dropdownPanelClass}>
<MenuActionButton
icon={mdiSwapHorizontal}
label='Navigation Button'
onClick={() =>
handleMenuAction(() =>
onAddElement(allowedNavigationTypes[0]),
)
}
/>
<MenuActionButton
icon={mdiSwapHorizontal}
label='Transition'
onClick={() =>
handleMenuAction(() =>
onSelectMenuItem('create_transition'),
)
}
/>
<MenuActionButton
icon={mdiImageMultiple}
label='Gallery'
onClick={() =>
handleMenuAction(() => onAddElement('gallery'))
}
/>
<MenuActionButton
icon={mdiViewCarousel}
label='Carousel'
onClick={() =>
handleMenuAction(() => onAddElement('carousel'))
}
/>
<MenuActionButton
icon={mdiTooltipText}
label='Tooltip'
onClick={() =>
handleMenuAction(() => onAddElement('tooltip'))
}
/>
<MenuActionButton
icon={mdiText}
label='Description'
onClick={() =>
handleMenuAction(() => onAddElement('description'))
}
/>
<MenuActionButton
icon={mdiVideo}
label='Video Player'
onClick={() =>
handleMenuAction(() => onAddElement('video_player'))
}
/>
<MenuActionButton
icon={mdiMusicNote}
label='Audio Player'
onClick={() =>
handleMenuAction(() => onAddElement('audio_player'))
}
/>
</div>
</ClickOutside>
)}
</div>
{/* Divider */}
<div className='w-px h-8 bg-white/30' />
{/* Create Page Button */}
<button
type='button'
onClick={onCreatePage}
disabled={isCreatingPage}
className={`${triggerBtnClass} ${isCreatingPage ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<BaseIcon path={mdiPlus} size={18} />
<span>{isCreatingPage ? 'Creating...' : 'Page'}</span>
</button>
{/* Save Button - reuse BaseButton with subtitle */}
<BaseButton
small
color='info'
label={isSaving ? 'Saving...' : 'Save'}
subtitle={
lastSavedAt
? dataFormatter.relativeTimestamp(lastSavedAt)
: undefined
}
onClick={onSave}
disabled={isSaving}
/>
{/* Save to Stage Button */}
<BaseButton
small
color='success'
label={isSavingToStage ? 'Saving...' : 'Stage'}
subtitle={
lastSavedToStageAt
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
: undefined
}
onClick={onSaveToStage}
disabled={isSavingToStage}
/>
{/* Exit Button */}
<button
type='button'
onClick={onExit}
className='flex items-center justify-center w-10 h-10 rounded text-red-400 hover:text-red-300 hover:bg-red-500/20 transition-colors'
title='Exit constructor'
>
<BaseIcon path={mdiExitToApp} size={26} />
</button>
{/* Collapse Toggle */}
<button
type='button'
onClick={() => setIsCollapsed(true)}
className='flex items-center justify-center w-10 h-10 rounded text-white/60 hover:text-white/90 hover:bg-white/20 transition-colors'
title='Collapse toolbar'
>
<BaseIcon path={mdiChevronLeft} size={26} />
</button>
</div>
);
},
);
ConstructorToolbar.displayName = 'ConstructorToolbar';
export default ConstructorToolbar;

View File

@ -0,0 +1,123 @@
/**
* CreatePageModal Component
*
* Modal dialog for creating new pages with custom name.
* Slug is auto-generated from the page name behind the scenes.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import CardBoxModal from '../CardBoxModal';
import { sanitizeSlug, buildUniqueSlug } from '../../lib/slugHelpers';
interface CreatePageModalProps {
/** Whether the modal is visible */
isActive: boolean;
/** Whether page creation is in progress */
isCreating: boolean;
/** Set of existing slugs for uniqueness validation */
existingSlugs: Set<string>;
/** Suggested page number for default name */
suggestedPageNumber: number;
/** Called when user confirms with valid name and slug */
onConfirm: (pageName: string, slug: string) => void;
/** Called when user cancels */
onCancel: () => void;
}
/**
* Modal for creating new pages - only asks for name, slug is auto-generated
*/
const CreatePageModal: React.FC<CreatePageModalProps> = ({
isActive,
isCreating,
existingSlugs,
suggestedPageNumber,
onConfirm,
onCancel,
}) => {
const [pageName, setPageName] = useState('');
const [nameError, setNameError] = useState('');
// Reset form when modal opens
useEffect(() => {
if (isActive) {
const defaultName = `Page ${suggestedPageNumber}`;
setPageName(defaultName);
setNameError('');
}
}, [isActive, suggestedPageNumber]);
// Validate name
const nameValidationError = useMemo(() => {
const trimmed = pageName.trim();
if (!trimmed) return 'Page name is required';
if (trimmed.length > 255) return 'Page name must be 255 characters or less';
return '';
}, [pageName]);
// Auto-generate unique slug from name
const generatedSlug = useMemo(() => {
const baseSlug = sanitizeSlug(pageName) || 'page';
return buildUniqueSlug(baseSlug, existingSlugs);
}, [pageName, existingSlugs]);
const handleNameChange = useCallback((value: string) => {
setPageName(value);
setNameError('');
}, []);
const handleConfirm = useCallback(() => {
if (nameValidationError) {
setNameError(nameValidationError);
return;
}
onConfirm(pageName.trim(), generatedSlug);
}, [pageName, generatedSlug, nameValidationError, onConfirm]);
const handleCancel = useCallback(() => {
if (!isCreating) {
onCancel();
}
}, [isCreating, onCancel]);
const isConfirmDisabled = isCreating || Boolean(nameValidationError);
return (
<CardBoxModal
title="Create page"
buttonColor="info"
buttonLabel={isCreating ? 'Creating...' : 'Create'}
isConfirmDisabled={isConfirmDisabled}
isActive={isActive}
onConfirm={handleConfirm}
onCancel={isCreating ? undefined : handleCancel}
>
<div>
<label
htmlFor="create-page-name"
className="block text-sm font-semibold mb-1"
>
Page name
</label>
<input
id="create-page-name"
type="text"
value={pageName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Enter page name"
className="w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800"
autoFocus
maxLength={255}
/>
{(nameError || nameValidationError) && (
<p className="text-xs text-red-600 mt-1">
{nameError || nameValidationError}
</p>
)}
</div>
</CardBoxModal>
);
};
export default CreatePageModal;

View File

@ -36,8 +36,8 @@ const CreateTransitionForm: React.FC<CreateTransitionFormProps> = ({
onSubmit, onSubmit,
}) => { }) => {
return ( return (
<div className='rounded border border-gray-200 p-2 space-y-2'> <div className='rounded border border-white/20 p-2 space-y-2'>
<p className='text-[11px] font-semibold text-gray-600'> <p className='text-[11px] font-semibold text-white/80'>
Create next page transition Create next page transition
</p> </p>
@ -61,11 +61,11 @@ const CreateTransitionForm: React.FC<CreateTransitionFormProps> = ({
))} ))}
</select> </select>
<p className='text-[11px] text-gray-500'> <p className='text-[11px] text-white/60'>
Transition duration is automatic from video metadata. {durationNote} Transition duration is automatic from video metadata. {durationNote}
</p> </p>
<label className='flex items-center gap-2 text-[11px] text-gray-700'> <label className='flex items-center gap-2 text-[11px] text-white/80'>
<input <input
type='checkbox' type='checkbox'
checked={supportsReverse} checked={supportsReverse}

View File

@ -26,16 +26,16 @@ const ElementEditorHeader: React.FC<ElementEditorHeaderProps> = ({
}) => { }) => {
return ( return (
<div <div
className='mb-3 flex items-center justify-between gap-2 cursor-move' className='mb-2 flex items-center justify-between gap-1 cursor-move'
onMouseDown={onDragStart} onMouseDown={onDragStart}
> >
<p className='text-xs font-bold uppercase tracking-wide text-gray-700'> <p className='text-[10px] font-bold uppercase tracking-wide text-white/90 truncate'>
{title} {title}
</p> </p>
<div className='flex items-center gap-2'> <div className='flex items-center gap-1.5 shrink-0'>
<button <button
type='button' type='button'
className='text-xs text-gray-700 hover:underline' className='text-[10px] text-white/70 hover:text-white hover:underline'
onClick={onToggleCollapse} onClick={onToggleCollapse}
> >
{isCollapsed ? 'Expand' : 'Collapse'} {isCollapsed ? 'Expand' : 'Collapse'}
@ -43,10 +43,10 @@ const ElementEditorHeader: React.FC<ElementEditorHeaderProps> = ({
{showRemoveButton && ( {showRemoveButton && (
<button <button
type='button' type='button'
className='text-xs text-red-600 hover:underline' className='text-[10px] text-red-400 hover:text-red-300 hover:underline'
onClick={onRemove} onClick={onRemove}
> >
Remove element Remove
</button> </button>
)} )}
</div> </div>

View File

@ -183,7 +183,7 @@ export function ElementEditorPanel({
return ( return (
<div <div
ref={elementEditorRef} ref={elementEditorRef}
className={`fixed z-[1000] ${isCollapsed ? 'w-[260px]' : 'w-[380px]'} max-h-[calc(100vh-2rem)] overflow-auto rounded-lg border border-gray-200 bg-white/95 p-3 shadow-xl`} className={`fixed z-[1000] ${isCollapsed ? 'w-[220px]' : 'w-[300px]'} max-h-[calc(100vh-1rem)] overflow-auto rounded-lg border border-white/30 bg-white/10 backdrop-blur-xl p-2 shadow-xl text-sm`}
style={{ left: position.x, top: position.y }} style={{ left: position.x, top: position.y }}
> >
<ElementEditorHeader <ElementEditorHeader
@ -324,6 +324,14 @@ export function ElementEditorPanel({
selectedElement.transitionReverseMode || 'auto_reverse' selectedElement.transitionReverseMode || 'auto_reverse'
} }
reverseVideoUrl={selectedElement.reverseVideoUrl || ''} reverseVideoUrl={selectedElement.reverseVideoUrl || ''}
transitionType={selectedElement.transitionType || ''}
transitionDurationMs={
selectedElement.transitionDurationMs ?? ''
}
transitionEasing={selectedElement.transitionEasing || ''}
transitionOverlayColor={
selectedElement.transitionOverlayColor || ''
}
allowedNavigationTypes={allowedNavigationTypes} allowedNavigationTypes={allowedNavigationTypes}
iconAssetOptions={assetOptions.icon} iconAssetOptions={assetOptions.icon}
transitionVideoOptions={assetOptions.transitionVideo} transitionVideoOptions={assetOptions.transitionVideo}
@ -539,7 +547,7 @@ export function ElementEditorPanel({
{/* Gallery Section Styles (shown first for gallery elements) */} {/* Gallery Section Styles (shown first for gallery elements) */}
{isGalleryElementType(selectedElement.type) && ( {isGalleryElementType(selectedElement.type) && (
<div className='space-y-2 mb-4'> <div className='space-y-2 mb-4'>
<p className='text-[11px] font-semibold text-gray-700'> <p className='text-[11px] font-semibold text-white/90'>
Gallery Section Styles Gallery Section Styles
</p> </p>
<GallerySectionStyleInputs <GallerySectionStyleInputs
@ -684,7 +692,7 @@ export function ElementEditorPanel({
showTitleStyles showTitleStyles
showAspectRatio showAspectRatio
/> />
<p className='text-[11px] font-semibold text-gray-700 pt-2'> <p className='text-[11px] font-semibold text-white/90 pt-2'>
General Element Styles General Element Styles
</p> </p>
</div> </div>
@ -733,6 +741,7 @@ export function ElementEditorPanel({
{/* Effects Tab */} {/* Effects Tab */}
{activeTab === 'effects' && ( {activeTab === 'effects' && (
<EffectsSettingsSectionCompact <EffectsSettingsSectionCompact
elementType={selectedElement.type}
values={{ values={{
appearAnimation: selectedElement.appearAnimation || '', appearAnimation: selectedElement.appearAnimation || '',
appearAnimationDuration: appearAnimationDuration:
@ -755,11 +764,106 @@ export function ElementEditorPanel({
activeOpacity: selectedElement.activeOpacity || '', activeOpacity: selectedElement.activeOpacity || '',
activeBackgroundColor: activeBackgroundColor:
selectedElement.activeBackgroundColor || '', selectedElement.activeBackgroundColor || '',
// Slide transition values (gallery/carousel)
slideTransitionType:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionType || ''
: selectedElement.carouselSlideTransitionType || '',
slideTransitionDurationMs:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionDurationMs !==
undefined &&
selectedElement.gallerySlideTransitionDurationMs !==
''
? String(
selectedElement.gallerySlideTransitionDurationMs,
)
: ''
: selectedElement.carouselSlideTransitionDurationMs !==
undefined &&
selectedElement.carouselSlideTransitionDurationMs !==
''
? String(
selectedElement.carouselSlideTransitionDurationMs,
)
: '',
slideTransitionEasing:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionEasing || ''
: selectedElement.carouselSlideTransitionEasing || '',
slideTransitionOverlayColor:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionOverlayColor ||
''
: selectedElement.carouselSlideTransitionOverlayColor ||
'',
}} }}
onChange={(prop, value) => { onChange={(prop, value) => {
updateSelectedElement({ // Handle slide transition properties with proper prefixes
[prop]: value || undefined, if (prop === 'slideTransitionType') {
}); const typedValue = (value || undefined) as
| 'fade'
| 'none'
| ''
| undefined;
if (selectedElement.type === 'gallery') {
updateSelectedElement({
gallerySlideTransitionType: typedValue,
});
} else if (selectedElement.type === 'carousel') {
updateSelectedElement({
carouselSlideTransitionType: typedValue,
});
}
} else if (prop === 'slideTransitionDurationMs') {
const ms = value ? parseInt(value, 10) : undefined;
const typedMs = ms !== undefined && ms > 0 ? ms : '';
if (selectedElement.type === 'gallery') {
updateSelectedElement({
gallerySlideTransitionDurationMs: typedMs,
});
} else if (selectedElement.type === 'carousel') {
updateSelectedElement({
carouselSlideTransitionDurationMs: typedMs,
});
}
} else if (prop === 'slideTransitionEasing') {
// Cast to proper type - form values are validated by select options
type EasingValue =
| 'ease-in-out'
| 'ease-in'
| 'ease-out'
| 'linear'
| ''
| undefined;
const typedEasing = (value || undefined) as EasingValue;
if (selectedElement.type === 'gallery') {
updateSelectedElement({
gallerySlideTransitionEasing: typedEasing,
});
} else if (selectedElement.type === 'carousel') {
updateSelectedElement({
carouselSlideTransitionEasing: typedEasing,
});
}
} else if (prop === 'slideTransitionOverlayColor') {
if (selectedElement.type === 'gallery') {
updateSelectedElement({
gallerySlideTransitionOverlayColor:
value || undefined,
});
} else if (selectedElement.type === 'carousel') {
updateSelectedElement({
carouselSlideTransitionOverlayColor:
value || undefined,
});
}
} else {
// Standard effect properties
updateSelectedElement({
[prop]: value || undefined,
});
}
}} }}
/> />
)} )}

View File

@ -10,23 +10,25 @@ import type { ConstructorInteractionMode } from './types';
interface InteractionModeToggleProps { interface InteractionModeToggleProps {
mode: ConstructorInteractionMode; mode: ConstructorInteractionMode;
onModeChange: (mode: ConstructorInteractionMode) => void; onModeChange: (mode: ConstructorInteractionMode) => void;
compact?: boolean;
} }
const InteractionModeToggle: React.FC<InteractionModeToggleProps> = ({ const InteractionModeToggle: React.FC<InteractionModeToggleProps> = ({
mode, mode,
onModeChange, onModeChange,
compact = false,
}) => { }) => {
const isEditMode = mode === 'edit'; const isEditMode = mode === 'edit';
return ( return (
<div className='flex flex-wrap items-center gap-2'> <div className='flex flex-wrap items-center gap-2'>
<div className='inline-flex overflow-hidden rounded border border-gray-300 bg-white text-xs font-semibold'> <div className='inline-flex overflow-hidden rounded border border-white/30 bg-white/10 text-xs font-semibold'>
<button <button
type='button' type='button'
className={`px-3 py-1.5 ${ className={`px-3 py-1.5 transition-colors ${
isEditMode isEditMode
? 'bg-blue-600 text-white' ? 'bg-blue-500/80 text-white'
: 'text-gray-700 hover:bg-gray-50' : 'text-white/70 hover:bg-white/10'
}`} }`}
onClick={() => onModeChange('edit')} onClick={() => onModeChange('edit')}
> >
@ -34,21 +36,23 @@ const InteractionModeToggle: React.FC<InteractionModeToggleProps> = ({
</button> </button>
<button <button
type='button' type='button'
className={`border-l border-gray-300 px-3 py-1.5 ${ className={`border-l border-white/30 px-3 py-1.5 transition-colors ${
!isEditMode !isEditMode
? 'bg-blue-600 text-white' ? 'bg-blue-500/80 text-white'
: 'text-gray-700 hover:bg-gray-50' : 'text-white/70 hover:bg-white/10'
}`} }`}
onClick={() => onModeChange('interact')} onClick={() => onModeChange('interact')}
> >
Interact mode Interact mode
</button> </button>
</div> </div>
<span className='text-[11px] text-gray-600'> {!compact && (
{isEditMode <span className='text-[11px] text-white/60'>
? 'Drag & configure elements.' {isEditMode
: 'Click and interact with rendered elements.'} ? 'Drag & configure elements.'
</span> : 'Click and interact with rendered elements.'}
</span>
)}
</div> </div>
); );
}; };

View File

@ -2,7 +2,7 @@
* MenuActionButton Component * MenuActionButton Component
* *
* Compact button for constructor menu actions. * Compact button for constructor menu actions.
* Used in ConstructorMenu for adding elements, backgrounds, etc. * Used in ConstructorToolbar for adding elements, backgrounds, etc.
*/ */
import React from 'react'; import React from 'react';

View File

@ -45,7 +45,10 @@ const PageSelector: React.FC<PageSelectorProps> = ({
return ( return (
<select <select
className='rounded border border-gray-300 bg-white px-3 py-2 text-sm' className='rounded border border-white/30 bg-white/20 pl-3 pr-8 py-1.5 text-sm text-white/90 backdrop-blur-sm focus:outline-none focus:ring-1 focus:ring-white/40 appearance-none bg-no-repeat bg-[length:16px] bg-[right_8px_center]'
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='rgba(255,255,255,0.7)' d='M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z'/%3E%3C/svg%3E")`,
}}
value={activePageId ?? ''} value={activePageId ?? ''}
onChange={(event) => onPageChange(event.target.value)} onChange={(event) => onPageChange(event.target.value)}
disabled={disabled} disabled={disabled}

View File

@ -7,63 +7,122 @@
* *
* Supports letterbox mode to constrain transitions within canvas bounds, * Supports letterbox mode to constrain transitions within canvas bounds,
* matching the behavior of background images and UI elements. * matching the behavior of background images and UI elements.
*
* Hide behavior:
* - Waits one requestAnimationFrame after isFadingOut=true
* - This ensures the new background is painted before hiding
* - Then hides instantly (no CSS transition) since last video frame = new bg
*/ */
import React from 'react'; import React, { useState, useEffect } from 'react';
import CanvasLoadingSpinner from '../CanvasLoadingSpinner';
interface TransitionPreviewOverlayProps { interface TransitionPreviewOverlayProps {
/** Reference to the video element - useTransitionPlayback manages src and playback */ /** Reference to the video element - useTransitionPlayback manages src and playback */
videoRef: React.RefObject<HTMLVideoElement | null>; videoRef: React.RefObject<HTMLVideoElement | null>;
/** Whether the overlay is visible */ /** Whether the overlay is visible */
isActive: boolean; isActive: boolean;
/** Whether the video is currently buffering (used to hide video during load) */ /** Whether the video is currently buffering (used to show spinner) */
isBuffering?: boolean; isBuffering?: boolean;
/** Whether first video frame has been displayed (used to determine if video should be visible during buffering) */
isVideoReady?: boolean;
/** Show loading spinner during buffering (default: false for backward compat) */
showSpinner?: boolean;
/** Letterbox styles from useCanvasScale - positions overlay within canvas bounds */ /** Letterbox styles from useCanvasScale - positions overlay within canvas bounds */
letterboxStyles?: React.CSSProperties; letterboxStyles?: React.CSSProperties;
/** Video object-fit mode (default: 'contain' to match backgrounds) */ /** Video object-fit mode (default: 'contain' to match backgrounds) */
videoFit?: 'contain' | 'cover'; videoFit?: 'contain' | 'cover';
/** Additional opacity value for fade-out effects (0-1) */ /** Additional opacity value for fade-out effects (0-1) */
opacity?: number; opacity?: number;
/** Forces video element remount when changed - prevents decoder state issues with pre-created blob URLs */
videoKey?: string;
/** When true, overlay will hide after one paint frame (ensures bg is painted first) */
isFadingOut?: boolean;
/** Fade-out duration in ms - kept for interface compat, not used for video transitions (instant hide) */
fadeOutDuration?: number;
} }
const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({ const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
videoRef, videoRef,
isActive, isActive,
isBuffering = false, isBuffering = false,
isVideoReady = false,
showSpinner = false,
letterboxStyles, letterboxStyles,
videoFit = 'contain', videoFit = 'contain',
opacity, opacity,
videoKey,
isFadingOut = false,
// fadeOutDuration - not used for video transitions (instant hide)
}) => { }) => {
// Delay hide by one frame to ensure new background is painted
const [shouldHide, setShouldHide] = useState(false);
useEffect(() => {
if (isFadingOut) {
// Wait one frame to ensure new background is painted
const rafId = requestAnimationFrame(() => {
setShouldHide(true);
});
return () => cancelAnimationFrame(rafId);
} else {
setShouldHide(false);
}
}, [isFadingOut]);
if (!isActive) return null; if (!isActive) return null;
// Container opacity: 0 while buffering to prevent black flash // Container opacity:
// Video first frame = old page background, so we hide everything until ready // - 0 during initial buffering (before first frame displayed)
const containerOpacity = isBuffering ? 0 : (opacity ?? 1); // - 0 when shouldHide (after one-frame delay, new bg is painted)
// - otherwise use provided opacity or 1
const isInitialBuffering = isBuffering && !isVideoReady;
const containerOpacity = isInitialBuffering
? 0
: shouldHide
? 0
: (opacity ?? 1);
// Only use transition for initial buffering fade-in (150ms)
// No transition when hiding - instant hide since last video frame = new bg
const useTransition = isInitialBuffering || (!shouldHide && !isFadingOut);
return ( return (
// Outer: full viewport, transparent background // Outer: full viewport, transparent background
// Transparent ensures if Safari clears video frame when paused, // Transparent ensures if Safari clears video frame when paused,
// the new page background shows through instead of black flash // the new page background shows through instead of black flash
<div <div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
className='fixed inset-0 z-50 overflow-hidden pointer-events-none' {/* Loading spinner during buffering */}
style={{ opacity: containerOpacity }} {isBuffering && showSpinner && (
> <CanvasLoadingSpinner isVisible={true} size='lg' zIndex={60} />
{/* Inner: respects letterbox dimensions when provided */} )}
{/* Video container - hidden while buffering or fading out */}
<div <div
className='overflow-hidden' style={{
style={letterboxStyles || { position: 'absolute', inset: 0 }} opacity: containerOpacity,
transition: useTransition ? 'opacity 150ms ease-out' : 'none',
}}
> >
{/* Video element - container handles visibility, video is always opaque */} {/* Inner: respects letterbox dimensions when provided */}
<video <div
ref={videoRef} className='overflow-hidden'
className={`absolute inset-0 h-full w-full ${ style={letterboxStyles || { position: 'absolute', inset: 0 }}
videoFit === 'cover' ? 'object-cover' : 'object-contain' >
}`} {/* Video element - container handles visibility, video is always opaque */}
muted {/* key forces React to remount the video element when URL changes, clearing decoder state */}
playsInline <video
preload='auto' key={videoKey}
disablePictureInPicture ref={videoRef}
/> className={`absolute inset-0 h-full w-full ${
videoFit === 'cover' ? 'object-cover' : 'object-contain'
}`}
muted
playsInline
preload='auto'
disablePictureInPicture
/>
</div>
</div> </div>
</div> </div>
); );

View File

@ -3,7 +3,7 @@
* *
* Types only - import components directly from their files: * Types only - import components directly from their files:
* import CanvasElement from './Constructor/CanvasElement'; * import CanvasElement from './Constructor/CanvasElement';
* import ConstructorMenu from './Constructor/ConstructorMenu'; * import ConstructorToolbar from './Constructor/ConstructorToolbar';
*/ */
export * from './types'; export * from './types';

View File

@ -83,6 +83,7 @@ export interface PageSelectorProps {
export interface InteractionModeToggleProps { export interface InteractionModeToggleProps {
mode: ConstructorInteractionMode; mode: ConstructorInteractionMode;
onModeChange: (mode: ConstructorInteractionMode) => void; onModeChange: (mode: ConstructorInteractionMode) => void;
compact?: boolean;
} }
/** /**
@ -108,7 +109,6 @@ export interface CanvasElementProps {
element: CanvasElement; element: CanvasElement;
isSelected: boolean; isSelected: boolean;
isEditMode: boolean; isEditMode: boolean;
isDisabled?: boolean;
canvasElapsedSec: number; canvasElapsedSec: number;
preloadedIconUrl: boolean; preloadedIconUrl: boolean;
onClick: (element: CanvasElement) => void; onClick: (element: CanvasElement) => void;
@ -301,6 +301,48 @@ export interface ConstructorMenuProps {
isSavingToStage: boolean; isSavingToStage: boolean;
} }
/**
* Unified toolbar props - combines controls panel and menu functionality
* Replaces ConstructorControlsPanelProps and ConstructorMenuProps
*/
export interface ConstructorToolbarProps {
// Positioning (from useDraggable)
position: Position;
onDragStart: (event: React.MouseEvent) => void;
// Page selector (reuse PageSelector component)
pages: TourPage[];
activePageId: string;
onPageChange: (pageId: string) => void;
// Mode toggle (reuse InteractionModeToggle with compact=true)
interactionMode: ConstructorInteractionMode;
onModeChange: (mode: ConstructorInteractionMode) => void;
// Background actions (opens ElementEditorPanel)
onSelectMenuItem: (item: EditorMenuItem) => void;
// Element actions
allowedNavigationTypes: NavigationElementType[];
onAddElement: (type: CanvasElementType) => void;
// Page actions
onCreatePage: () => void;
isCreatingPage: boolean;
// Save actions (reuse BaseButton with subtitle for timestamps)
onSave: () => void;
onSaveToStage: () => void;
isSaving: boolean;
isSavingToStage: boolean;
lastSavedAt?: string | null;
lastSavedToStageAt?: string | null;
// Exit
projectId: string;
onExit: () => void;
}
/** /**
* Menu action button props * Menu action button props
*/ */

View File

@ -71,14 +71,14 @@ const CarouselSettingsSectionCompact: React.FC<
/> />
<label <label
htmlFor='carouselFullWidth' htmlFor='carouselFullWidth'
className='text-[11px] text-gray-700' className='text-[11px] text-white/80'
> >
Full-width mode (background layer) Full-width mode (background layer)
</label> </label>
</div> </div>
<div className='rounded border border-gray-200 p-2 space-y-2'> <div className='rounded border border-white/20 p-2 space-y-2'>
<p className='text-[11px] font-semibold text-gray-700'> <p className='text-[11px] font-semibold text-white/90'>
Navigation icons Navigation icons
</p> </p>
@ -177,7 +177,7 @@ const CarouselSettingsSectionCompact: React.FC<
)} )}
<div> <div>
<label className='text-[10px] text-gray-600'>Caption font:</label> <label className='text-[10px] text-white/70'>Caption font:</label>
<select <select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={carouselCaptionFontFamily} value={carouselCaptionFontFamily}
@ -195,7 +195,7 @@ const CarouselSettingsSectionCompact: React.FC<
</div> </div>
{carouselFullWidth && ( {carouselFullWidth && (
<p className='text-[10px] text-gray-500 mt-1'> <p className='text-[10px] text-white/60 mt-1'>
In full-width mode: set icon + dimensions for navigation-style In full-width mode: set icon + dimensions for navigation-style
buttons. Drag to reposition in editor. buttons. Drag to reposition in editor.
</p> </p>
@ -203,7 +203,7 @@ const CarouselSettingsSectionCompact: React.FC<
</div> </div>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<p className='text-[11px] font-semibold text-gray-600'> <p className='text-[11px] font-semibold text-white/80'>
Carousel slides Carousel slides
</p> </p>
<button <button
@ -218,10 +218,10 @@ const CarouselSettingsSectionCompact: React.FC<
{carouselSlides.map((slide, index) => ( {carouselSlides.map((slide, index) => (
<div <div
key={slide.id} key={slide.id}
className='rounded border border-gray-200 p-2 space-y-2' className='rounded border border-white/20 p-2 space-y-2'
> >
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<p className='text-[11px] font-semibold text-gray-700'> <p className='text-[11px] font-semibold text-white/90'>
Slide {index + 1} Slide {index + 1}
</p> </p>
<button <button
@ -264,7 +264,7 @@ const CarouselSettingsSectionCompact: React.FC<
))} ))}
{carouselSlides.length === 0 && ( {carouselSlides.length === 0 && (
<p className='text-[11px] text-gray-500'> <p className='text-[11px] text-white/60'>
No slides yet. Click &quot;+ Add slide&quot; to create one. No slides yet. Click &quot;+ Add slide&quot; to create one.
</p> </p>
)} )}

View File

@ -16,25 +16,25 @@ const CommonSettingsSectionCompact: React.FC<CommonSettingsSectionProps> = ({
showLabel = true, showLabel = true,
}) => { }) => {
return ( return (
<div className='mb-2 space-y-2'> <div className='mb-1.5 space-y-1.5'>
{showLabel && ( {showLabel && (
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-0.5 block text-[10px] font-semibold text-white/80'>
Label Label
</label> </label>
<input <input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-1.5 py-0.5 text-[11px]'
value={label} value={label}
onChange={(event) => onChange('label', event.target.value)} onChange={(event) => onChange('label', event.target.value)}
/> />
</div> </div>
)} )}
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-0.5 block text-[10px] font-semibold text-white/80'>
Appear delay (sec) Appear delay (sec)
</label> </label>
<input <input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-1.5 py-0.5 text-[11px]'
type='number' type='number'
min='0' min='0'
step='0.1' step='0.1'
@ -43,11 +43,11 @@ const CommonSettingsSectionCompact: React.FC<CommonSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-0.5 block text-[10px] font-semibold text-white/80'>
Appear duration (sec) Appear duration (sec)
</label> </label>
<input <input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-1.5 py-0.5 text-[11px]'
type='number' type='number'
min='0.1' min='0.1'
step='0.1' step='0.1'
@ -57,7 +57,7 @@ const CommonSettingsSectionCompact: React.FC<CommonSettingsSectionProps> = ({
onChange('appearDurationSec', event.target.value) onChange('appearDurationSec', event.target.value)
} }
/> />
<p className='mt-1 text-[11px] text-gray-500'> <p className='mt-0.5 text-[10px] text-white/60'>
Leave empty for unlimited. Leave empty for unlimited.
</p> </p>
</div> </div>

View File

@ -42,7 +42,7 @@ const DescriptionSettingsSectionCompact: React.FC<
return ( return (
<div className='space-y-2'> <div className='space-y-2'>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Icon Icon
</label> </label>
<select <select
@ -64,7 +64,7 @@ const DescriptionSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Description title Description title
</label> </label>
<input <input
@ -75,7 +75,7 @@ const DescriptionSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Description text Description text
</label> </label>
<textarea <textarea
@ -87,7 +87,7 @@ const DescriptionSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Title font size (px) Title font size (px)
</label> </label>
<input <input
@ -101,7 +101,7 @@ const DescriptionSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Text font size (px) Text font size (px)
</label> </label>
<input <input
@ -115,7 +115,7 @@ const DescriptionSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Title font family Title font family
</label> </label>
<select <select
@ -135,7 +135,7 @@ const DescriptionSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Text font family Text font family
</label> </label>
<select <select
@ -155,7 +155,7 @@ const DescriptionSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Title color Title color
</label> </label>
<input <input
@ -169,7 +169,7 @@ const DescriptionSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Text color Text color
</label> </label>
<input <input

View File

@ -6,22 +6,30 @@
*/ */
import React from 'react'; import React from 'react';
import type { EffectsSettingsSectionProps } from './types'; import type { EffectsSettingsFormValues } from './types';
import type { CanvasElementType } from '../../types/constructor';
const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({ interface EffectsSettingsSectionCompactProps {
values, values: EffectsSettingsFormValues;
onChange, onChange: (prop: keyof EffectsSettingsFormValues, value: string) => void;
}) => { elementType?: CanvasElementType;
}
const EffectsSettingsSectionCompact: React.FC<
EffectsSettingsSectionCompactProps
> = ({ values, onChange, elementType }) => {
const showSlideTransition =
elementType === 'gallery' || elementType === 'carousel';
return ( return (
<div className='space-y-3'> <div className='space-y-3'>
{/* Appear Animation */} {/* Appear Animation */}
<div> <div>
<p className='mb-2 text-[11px] font-semibold text-gray-700'> <p className='mb-2 text-[11px] font-semibold text-white/90'>
Appear Animation Appear Animation
</p> </p>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
<div className='col-span-2'> <div className='col-span-2'>
<label className='mb-1 block text-[10px] text-gray-500'>Type</label> <label className='mb-1 block text-[10px] text-white/60'>Type</label>
<select <select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.appearAnimation || ''} value={values.appearAnimation || ''}
@ -37,7 +45,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
</select> </select>
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
Duration (sec) Duration (sec)
</label> </label>
<input <input
@ -50,7 +58,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
Easing Easing
</label> </label>
<select <select
@ -72,12 +80,12 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
{/* Hover Effects */} {/* Hover Effects */}
<div> <div>
<p className='mb-2 text-[11px] font-semibold text-gray-700'> <p className='mb-2 text-[11px] font-semibold text-white/90'>
Hover Effects Hover Effects
</p> </p>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
Scale Scale
</label> </label>
<input <input
@ -88,7 +96,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
Opacity Opacity
</label> </label>
<input <input
@ -99,7 +107,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
BG color BG color
</label> </label>
<input <input
@ -110,7 +118,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
Text color Text color
</label> </label>
<input <input
@ -121,7 +129,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
/> />
</div> </div>
<div className='col-span-2'> <div className='col-span-2'>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
Box shadow Box shadow
</label> </label>
<input <input
@ -132,7 +140,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
/> />
</div> </div>
<div className='col-span-2'> <div className='col-span-2'>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
Transition (sec) Transition (sec)
</label> </label>
<input <input
@ -149,12 +157,12 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
{/* Focus Effects */} {/* Focus Effects */}
<div> <div>
<p className='mb-2 text-[11px] font-semibold text-gray-700'> <p className='mb-2 text-[11px] font-semibold text-white/90'>
Focus Effects Focus Effects
</p> </p>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
Scale Scale
</label> </label>
<input <input
@ -165,7 +173,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
Opacity Opacity
</label> </label>
<input <input
@ -176,7 +184,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
Outline Outline
</label> </label>
<input <input
@ -187,7 +195,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
Box shadow Box shadow
</label> </label>
<input <input
@ -202,12 +210,12 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
{/* Active/Press Effects */} {/* Active/Press Effects */}
<div> <div>
<p className='mb-2 text-[11px] font-semibold text-gray-700'> <p className='mb-2 text-[11px] font-semibold text-white/90'>
Active/Press Effects Active/Press Effects
</p> </p>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
Scale Scale
</label> </label>
<input <input
@ -218,7 +226,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
Opacity Opacity
</label> </label>
<input <input
@ -229,7 +237,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
/> />
</div> </div>
<div className='col-span-2'> <div className='col-span-2'>
<label className='mb-1 block text-[10px] text-gray-500'> <label className='mb-1 block text-[10px] text-white/60'>
BG color BG color
</label> </label>
<input <input
@ -243,6 +251,101 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
</div> </div>
</div> </div>
</div> </div>
{/* Slide Transition Override - Gallery/Carousel only */}
{showSlideTransition && (
<div className='mt-3 border-t border-white/20 pt-3'>
<p className='mb-1 text-[11px] font-semibold text-white/90'>
Slide Transition
</p>
<p className='mb-2 text-[10px] text-white/60'>
Override page transition for slides. Leave empty for defaults.
</p>
<div className='grid grid-cols-2 gap-2'>
{/* Type */}
<div>
<label className='mb-1 block text-[10px] text-white/60'>
Type
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.slideTransitionType || ''}
onChange={(e) =>
onChange('slideTransitionType', e.target.value)
}
>
<option value=''>Use Default</option>
<option value='fade'>Fade</option>
<option value='none'>None (Instant)</option>
</select>
</div>
{/* Duration */}
<div>
<label className='mb-1 block text-[10px] text-white/60'>
Duration (ms)
</label>
<input
type='number'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.slideTransitionDurationMs || ''}
onChange={(e) =>
onChange('slideTransitionDurationMs', e.target.value)
}
placeholder='400'
min='0'
step='50'
/>
</div>
{/* Easing */}
<div>
<label className='mb-1 block text-[10px] text-white/60'>
Easing
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.slideTransitionEasing || ''}
onChange={(e) =>
onChange('slideTransitionEasing', e.target.value)
}
>
<option value=''>Use Default</option>
<option value='ease-in-out'>Ease In-Out</option>
<option value='ease-in'>Ease In</option>
<option value='ease-out'>Ease Out</option>
<option value='linear'>Linear</option>
</select>
</div>
{/* Overlay Color */}
<div>
<label className='mb-1 block text-[10px] text-white/60'>
Overlay Color
</label>
<div className='flex gap-1'>
<input
type='color'
className='h-[26px] w-8 cursor-pointer rounded border border-gray-300'
value={values.slideTransitionOverlayColor || '#000000'}
onChange={(e) =>
onChange('slideTransitionOverlayColor', e.target.value)
}
/>
<input
type='text'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.slideTransitionOverlayColor || ''}
onChange={(e) =>
onChange('slideTransitionOverlayColor', e.target.value)
}
placeholder='#000000'
/>
</div>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@ -42,15 +42,15 @@ export const ElementSettingsTabsCompact: React.FC<ElementSettingsTabsProps> = ({
tabs, tabs,
}) => { }) => {
return ( return (
<div className='mb-3 inline-flex w-full overflow-hidden rounded border border-gray-300'> <div className='mb-2 inline-flex w-full overflow-hidden rounded border border-white/30'>
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
type='button' type='button'
className={`flex-1 px-2 py-1.5 text-[11px] font-semibold transition-colors ${ className={`flex-1 px-1.5 py-1 text-[10px] font-semibold transition-colors ${
activeTab === tab.id activeTab === tab.id
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50' : 'bg-white/20 text-white/80 hover:bg-white/30'
}`} }`}
onClick={() => onTabChange(tab.id)} onClick={() => onTabChange(tab.id)}
> >

View File

@ -54,13 +54,13 @@ const GalleryCarouselSettingsSectionCompact: React.FC<
}) => { }) => {
return ( return (
<div className='mt-3 space-y-2'> <div className='mt-3 space-y-2'>
<div className='rounded border border-gray-200 p-2 space-y-2'> <div className='rounded border border-white/20 p-2 space-y-2'>
<p className='text-[11px] font-semibold text-gray-700'> <p className='text-[11px] font-semibold text-white/90'>
Carousel navigation Carousel navigation
</p> </p>
{/* Previous button */} {/* Previous button */}
<p className='text-[10px] font-medium text-gray-600 mt-1'>Previous</p> <p className='text-[10px] font-medium text-white/70 mt-1'>Previous</p>
<select <select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={prevIconUrl} value={prevIconUrl}
@ -111,7 +111,7 @@ const GalleryCarouselSettingsSectionCompact: React.FC<
)} )}
{/* Next button */} {/* Next button */}
<p className='text-[10px] font-medium text-gray-600 mt-1'>Next</p> <p className='text-[10px] font-medium text-white/70 mt-1'>Next</p>
<select <select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={nextIconUrl} value={nextIconUrl}
@ -162,7 +162,7 @@ const GalleryCarouselSettingsSectionCompact: React.FC<
)} )}
{/* Back button */} {/* Back button */}
<p className='text-[10px] font-medium text-gray-600 mt-1'>Back</p> <p className='text-[10px] font-medium text-white/70 mt-1'>Back</p>
<select <select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={backIconUrl} value={backIconUrl}
@ -221,7 +221,7 @@ const GalleryCarouselSettingsSectionCompact: React.FC<
/> />
)} )}
<p className='text-[10px] text-gray-500 mt-1'> <p className='text-[10px] text-white/60 mt-1'>
Set icon + dimensions for navigation-style buttons. Drag to Set icon + dimensions for navigation-style buttons. Drag to
reposition. reposition.
</p> </p>

View File

@ -41,13 +41,13 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
showTextAlign = false, showTextAlign = false,
}) => { }) => {
return ( return (
<div className='rounded border border-gray-200 p-2 space-y-2'> <div className='rounded border border-white/20 p-2 space-y-2'>
<p className='text-[11px] font-semibold text-gray-700'>{sectionLabel}</p> <p className='text-[11px] font-semibold text-white/90'>{sectionLabel}</p>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
{/* Background Color */} {/* Background Color */}
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
BG color BG color
</label> </label>
<input <input
@ -63,7 +63,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Text Color (not for wrapper) */} {/* Text Color (not for wrapper) */}
{prefix !== 'galleryWrapper' && !showTitleStyles && ( {prefix !== 'galleryWrapper' && !showTitleStyles && (
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Text color Text color
</label> </label>
<input <input
@ -77,7 +77,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Padding */} {/* Padding */}
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Padding Padding
</label> </label>
<input <input
@ -90,7 +90,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Border Radius */} {/* Border Radius */}
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'>Radius</label> <label className='mb-1 block text-[10px] text-white/70'>Radius</label>
<input <input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}BorderRadius`] || ''} value={values[`${prefix}BorderRadius`] || ''}
@ -101,7 +101,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Border */} {/* Border */}
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'>Border</label> <label className='mb-1 block text-[10px] text-white/70'>Border</label>
<input <input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}Border`] || ''} value={values[`${prefix}Border`] || ''}
@ -113,7 +113,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Text Alignment (optional) */} {/* Text Alignment (optional) */}
{showTextAlign && ( {showTextAlign && (
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Align Align
</label> </label>
<select <select
@ -131,7 +131,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Gap (optional) */} {/* Gap (optional) */}
{showGap && ( {showGap && (
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'>Gap</label> <label className='mb-1 block text-[10px] text-white/70'>Gap</label>
<input <input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}Gap`] || ''} value={values[`${prefix}Gap`] || ''}
@ -144,7 +144,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Backdrop Blur (wrapper only) */} {/* Backdrop Blur (wrapper only) */}
{showBlur && ( {showBlur && (
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'>Blur</label> <label className='mb-1 block text-[10px] text-white/70'>Blur</label>
<input <input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}BackdropBlur`] || ''} value={values[`${prefix}BackdropBlur`] || ''}
@ -159,7 +159,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Grid Columns (optional) */} {/* Grid Columns (optional) */}
{showColumns && ( {showColumns && (
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Columns Columns
</label> </label>
<input <input
@ -179,7 +179,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Font Size (optional) */} {/* Font Size (optional) */}
{showFont && ( {showFont && (
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Font size Font size
</label> </label>
<input <input
@ -194,7 +194,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Font Weight (optional) */} {/* Font Weight (optional) */}
{showFont && ( {showFont && (
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Font weight Font weight
</label> </label>
<input <input
@ -210,7 +210,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Font Family (optional - full width) */} {/* Font Family (optional - full width) */}
{showFont && ( {showFont && (
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'>Font</label> <label className='mb-1 block text-[10px] text-white/70'>Font</label>
<select <select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs' className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values[`${prefix}FontFamily`] || ''} value={values[`${prefix}FontFamily`] || ''}
@ -229,10 +229,10 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Header Dimensions (header section only) */} {/* Header Dimensions (header section only) */}
{showDimensions && ( {showDimensions && (
<> <>
<p className='text-[10px] text-gray-500 pt-1'>Dimensions:</p> <p className='text-[10px] text-white/60 pt-1'>Dimensions:</p>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Width Width
</label> </label>
<input <input
@ -243,7 +243,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Height Height
</label> </label>
<input <input
@ -254,7 +254,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Min Height Min Height
</label> </label>
<input <input
@ -265,7 +265,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Max Height Max Height
</label> </label>
<input <input
@ -282,10 +282,10 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Card Aspect Ratio and Min Height (cards only) */} {/* Card Aspect Ratio and Min Height (cards only) */}
{showAspectRatio && ( {showAspectRatio && (
<> <>
<p className='text-[10px] text-gray-500 pt-1'>Card dimensions:</p> <p className='text-[10px] text-white/60 pt-1'>Card dimensions:</p>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Aspect Ratio Aspect Ratio
</label> </label>
<select <select
@ -303,7 +303,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
</select> </select>
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Min Height Min Height
</label> </label>
<input <input
@ -320,10 +320,10 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
{/* Card Title Styles (cards only) */} {/* Card Title Styles (cards only) */}
{showTitleStyles && ( {showTitleStyles && (
<> <>
<p className='text-[10px] text-gray-500 pt-1'>Card title overlay:</p> <p className='text-[10px] text-white/60 pt-1'>Card title overlay:</p>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Title color Title color
</label> </label>
<input <input
@ -336,7 +336,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Title BG Title BG
</label> </label>
<input <input
@ -349,7 +349,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Title size Title size
</label> </label>
<input <input
@ -362,7 +362,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Title weight Title weight
</label> </label>
<input <input
@ -376,7 +376,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
</div> </div>
</div> </div>
<div> <div>
<label className='mb-1 block text-[10px] text-gray-600'> <label className='mb-1 block text-[10px] text-white/70'>
Title shadow Title shadow
</label> </label>
<input <input

View File

@ -60,8 +60,8 @@ const GallerySettingsSectionCompact: React.FC<
return ( return (
<div className='space-y-3'> <div className='space-y-3'>
{/* Header Settings */} {/* Header Settings */}
<div className='rounded border border-gray-200 p-2 space-y-2'> <div className='rounded border border-white/20 p-2 space-y-2'>
<p className='text-[11px] font-semibold text-gray-700'> <p className='text-[11px] font-semibold text-white/90'>
Gallery header Gallery header
</p> </p>
@ -104,9 +104,9 @@ const GallerySettingsSectionCompact: React.FC<
</div> </div>
{/* Info Spans */} {/* Info Spans */}
<div className='rounded border border-gray-200 p-2 space-y-2'> <div className='rounded border border-white/20 p-2 space-y-2'>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<p className='text-[11px] font-semibold text-gray-700'>Info spans</p> <p className='text-[11px] font-semibold text-white/90'>Info spans</p>
<button <button
type='button' type='button'
className='text-xs text-blue-700 hover:underline' className='text-xs text-blue-700 hover:underline'
@ -157,7 +157,7 @@ const GallerySettingsSectionCompact: React.FC<
))} ))}
{galleryInfoSpans.length === 0 && ( {galleryInfoSpans.length === 0 && (
<p className='text-[10px] text-gray-500'> <p className='text-[10px] text-white/60'>
Add spans for brief notes (capacity, price, icons, etc.) Add spans for brief notes (capacity, price, icons, etc.)
</p> </p>
)} )}
@ -165,7 +165,7 @@ const GallerySettingsSectionCompact: React.FC<
{/* Gallery Cards */} {/* Gallery Cards */}
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<p className='text-[11px] font-semibold text-gray-600'>Gallery cards</p> <p className='text-[11px] font-semibold text-white/80'>Gallery cards</p>
<button <button
type='button' type='button'
className='text-xs text-blue-700 hover:underline' className='text-xs text-blue-700 hover:underline'
@ -178,10 +178,10 @@ const GallerySettingsSectionCompact: React.FC<
{galleryCards.map((card, index) => ( {galleryCards.map((card, index) => (
<div <div
key={card.id} key={card.id}
className='rounded border border-gray-200 p-2 space-y-2' className='rounded border border-white/20 p-2 space-y-2'
> >
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<p className='text-[11px] font-semibold text-gray-700'> <p className='text-[11px] font-semibold text-white/90'>
Card {index + 1} Card {index + 1}
</p> </p>
<button <button
@ -234,7 +234,7 @@ const GallerySettingsSectionCompact: React.FC<
))} ))}
{galleryCards.length === 0 && ( {galleryCards.length === 0 && (
<p className='text-[11px] text-gray-500'> <p className='text-[11px] text-white/60'>
No cards yet. Click &quot;+ Add card&quot; to create one. No cards yet. Click &quot;+ Add card&quot; to create one.
</p> </p>
)} )}

View File

@ -39,7 +39,7 @@ const MediaSettingsSectionCompact: React.FC<
return ( return (
<div className='space-y-2'> <div className='space-y-2'>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
{assetLabel} {assetLabel}
</label> </label>
<select <select
@ -60,7 +60,7 @@ const MediaSettingsSectionCompact: React.FC<
</select> </select>
</div> </div>
<label className='flex items-center gap-2 text-[11px] text-gray-700'> <label className='flex items-center gap-2 text-[11px] text-white/80'>
<input <input
type='checkbox' type='checkbox'
checked={mediaAutoplay} checked={mediaAutoplay}
@ -69,7 +69,7 @@ const MediaSettingsSectionCompact: React.FC<
Autoplay Autoplay
</label> </label>
<label className='flex items-center gap-2 text-[11px] text-gray-700'> <label className='flex items-center gap-2 text-[11px] text-white/80'>
<input <input
type='checkbox' type='checkbox'
checked={mediaLoop} checked={mediaLoop}
@ -79,7 +79,7 @@ const MediaSettingsSectionCompact: React.FC<
</label> </label>
{mediaType === 'video' && ( {mediaType === 'video' && (
<label className='flex items-center gap-2 text-[11px] text-gray-700'> <label className='flex items-center gap-2 text-[11px] text-white/80'>
<input <input
type='checkbox' type='checkbox'
checked={mediaMuted} checked={mediaMuted}

View File

@ -12,9 +12,24 @@ import type {
NavigationButtonKind, NavigationButtonKind,
CanvasElementType, CanvasElementType,
} from '../../types/constructor'; } from '../../types/constructor';
import type { TransitionType, EasingFunction } from '../../types/transition';
import { addFallbackAssetOption } from '../../lib/constructorHelpers'; import { addFallbackAssetOption } from '../../lib/constructorHelpers';
import { FONT_OPTIONS } from '../../lib/fonts'; import { FONT_OPTIONS } from '../../lib/fonts';
const CSS_TRANSITION_TYPES: { value: TransitionType | ''; label: string }[] = [
{ value: '', label: 'Use Project Default' },
{ value: 'fade', label: 'Fade' },
{ value: 'none', label: 'None (instant)' },
];
const CSS_EASING_OPTIONS: { value: EasingFunction | ''; label: string }[] = [
{ value: '', label: 'Use Project Default' },
{ value: 'ease-in-out', label: 'Ease In-Out' },
{ value: 'ease-in', label: 'Ease In' },
{ value: 'ease-out', label: 'Ease Out' },
{ value: 'linear', label: 'Linear' },
];
type NavigationElementType = Extract< type NavigationElementType = Extract<
CanvasElementType, CanvasElementType,
'navigation_next' | 'navigation_prev' 'navigation_next' | 'navigation_prev'
@ -31,6 +46,11 @@ interface NavigationSettingsSectionCompactProps {
transitionVideoUrl: string; transitionVideoUrl: string;
transitionReverseMode: 'auto_reverse' | 'separate_video'; transitionReverseMode: 'auto_reverse' | 'separate_video';
reverseVideoUrl: string; reverseVideoUrl: string;
// CSS transition settings (used when no video is selected)
transitionType?: TransitionType | '';
transitionDurationMs?: number | '';
transitionEasing?: EasingFunction | '';
transitionOverlayColor?: string;
allowedNavigationTypes: NavigationElementType[]; allowedNavigationTypes: NavigationElementType[];
iconAssetOptions: AssetOption[]; iconAssetOptions: AssetOption[];
transitionVideoOptions: AssetOption[]; transitionVideoOptions: AssetOption[];
@ -67,6 +87,10 @@ const NavigationSettingsSectionCompact: React.FC<
transitionVideoUrl, transitionVideoUrl,
transitionReverseMode, transitionReverseMode,
reverseVideoUrl, reverseVideoUrl,
transitionType = '',
transitionDurationMs = '',
transitionEasing = '',
transitionOverlayColor = '',
allowedNavigationTypes, allowedNavigationTypes,
iconAssetOptions, iconAssetOptions,
transitionVideoOptions, transitionVideoOptions,
@ -83,7 +107,7 @@ const NavigationSettingsSectionCompact: React.FC<
return ( return (
<div className='space-y-2'> <div className='space-y-2'>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Type Type
</label> </label>
<select <select
@ -116,7 +140,7 @@ const NavigationSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Button text Button text
</label> </label>
<input <input
@ -127,7 +151,7 @@ const NavigationSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Label font Label font
</label> </label>
<select <select
@ -147,7 +171,7 @@ const NavigationSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='flex items-center gap-2 text-[11px] font-semibold text-gray-600'> <label className='flex items-center gap-2 text-[11px] font-semibold text-white/80'>
<input <input
type='checkbox' type='checkbox'
checked={navDisabled} checked={navDisabled}
@ -158,7 +182,7 @@ const NavigationSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Icon Icon
</label> </label>
<select <select
@ -178,7 +202,7 @@ const NavigationSettingsSectionCompact: React.FC<
))} ))}
</select> </select>
{selectedMediaDurationNote && ( {selectedMediaDurationNote && (
<p className='mt-1 text-[11px] text-gray-500'> <p className='mt-1 text-[11px] text-white/60'>
{selectedMediaDurationNote} {selectedMediaDurationNote}
</p> </p>
)} )}
@ -186,7 +210,7 @@ const NavigationSettingsSectionCompact: React.FC<
{/* Back navigation info text */} {/* Back navigation info text */}
{currentKind === 'back' && ( {currentKind === 'back' && (
<p className='text-[11px] italic text-gray-500'> <p className='text-[11px] italic text-white/60'>
Back button returns to the previous page using the original forward Back button returns to the previous page using the original forward
transition in reverse. transition in reverse.
</p> </p>
@ -196,7 +220,7 @@ const NavigationSettingsSectionCompact: React.FC<
{currentKind === 'forward' && ( {currentKind === 'forward' && (
<> <>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Target page Target page
</label> </label>
<select <select
@ -219,7 +243,7 @@ const NavigationSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Transition video asset Transition video asset
</label> </label>
<select <select
@ -241,14 +265,14 @@ const NavigationSettingsSectionCompact: React.FC<
))} ))}
</select> </select>
{selectedTransitionDurationNote && ( {selectedTransitionDurationNote && (
<p className='mt-1 text-[11px] text-gray-500'> <p className='mt-1 text-[11px] text-white/60'>
{selectedTransitionDurationNote} {selectedTransitionDurationNote}
</p> </p>
)} )}
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Back transition mode Back transition mode
</label> </label>
<select <select
@ -274,7 +298,7 @@ const NavigationSettingsSectionCompact: React.FC<
{transitionReverseMode === 'separate_video' && ( {transitionReverseMode === 'separate_video' && (
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Back transition video asset Back transition video asset
</label> </label>
<select <select
@ -298,9 +322,99 @@ const NavigationSettingsSectionCompact: React.FC<
</div> </div>
)} )}
<p className='text-[11px] text-gray-500'> {/* CSS Transition Settings (when no video selected) */}
Transition duration is set automatically from the selected video. {!transitionVideoUrl && (
</p> <>
<p className='mt-2 text-[11px] italic text-white/60'>
No transition video selected. Configure CSS transition instead:
</p>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Transition type
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionType}
onChange={(event) =>
onChange('transitionType', event.target.value)
}
>
{CSS_TRANSITION_TYPES.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Duration (ms)
</label>
<input
type='number'
min='0'
placeholder='Use project default'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionDurationMs}
onChange={(event) => {
const val = event.target.value;
onChange(
'transitionDurationMs',
val === '' ? '' : Math.max(0, parseInt(val, 10) || 0),
);
}}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Easing
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionEasing}
onChange={(event) =>
onChange('transitionEasing', event.target.value)
}
>
{CSS_EASING_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-white/80'>
Overlay color
</label>
<div className='flex gap-2'>
<input
type='color'
className='h-7 w-10 cursor-pointer rounded border border-gray-300 p-0.5'
value={transitionOverlayColor || '#000000'}
onChange={(event) =>
onChange('transitionOverlayColor', event.target.value)
}
/>
<input
type='text'
placeholder='Use project default'
className='flex-1 rounded border border-gray-300 px-2 py-1 text-xs'
value={transitionOverlayColor}
onChange={(event) =>
onChange('transitionOverlayColor', event.target.value)
}
/>
</div>
</div>
</>
)}
{transitionVideoUrl && (
<p className='text-[11px] text-white/60'>
Transition duration is set automatically from the selected video.
</p>
)}
{onPreviewTransition && ( {onPreviewTransition && (
<div className='flex gap-2 pt-1'> <div className='flex gap-2 pt-1'>

View File

@ -14,12 +14,12 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
}) => { }) => {
return ( return (
<div className='space-y-2'> <div className='space-y-2'>
<p className='text-[10px] text-gray-500'> <p className='text-[10px] text-white/60'>
Dimensions = % of canvas, border/radius = px Dimensions = % of canvas, border/radius = px
</p> </p>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Width (%) Width (%)
</label> </label>
<input <input
@ -33,7 +33,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Height (%) Height (%)
</label> </label>
<input <input
@ -47,7 +47,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Min W (%) Min W (%)
</label> </label>
<input <input
@ -60,7 +60,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Max W (%) Max W (%)
</label> </label>
<input <input
@ -73,7 +73,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Min H (%) Min H (%)
</label> </label>
<input <input
@ -86,7 +86,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Max H (%) Max H (%)
</label> </label>
<input <input
@ -99,7 +99,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Margin Margin
</label> </label>
<input <input
@ -110,7 +110,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Padding Padding
</label> </label>
<input <input
@ -121,7 +121,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Gap (rem) Gap (rem)
</label> </label>
<input <input
@ -135,7 +135,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Font size Font size
</label> </label>
<input <input
@ -146,7 +146,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Line height Line height
</label> </label>
<input <input
@ -156,7 +156,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Font weight Font weight
</label> </label>
<input <input
@ -167,7 +167,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Border (px) Border (px)
</label> </label>
<input <input
@ -181,7 +181,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Radius (px) Radius (px)
</label> </label>
<input <input
@ -195,7 +195,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Opacity Opacity
</label> </label>
<input <input
@ -206,7 +206,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
z-index z-index
</label> </label>
<input <input
@ -219,7 +219,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Box shadow Box shadow
</label> </label>
<input <input
@ -232,7 +232,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Display Display
</label> </label>
<select <select
@ -250,7 +250,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
</select> </select>
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Position Position
</label> </label>
<select <select
@ -267,7 +267,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
</select> </select>
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Justify Justify
</label> </label>
<select <select
@ -285,7 +285,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
</select> </select>
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Align Align
</label> </label>
<select <select
@ -302,7 +302,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
</select> </select>
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Text align Text align
</label> </label>
<select <select
@ -318,7 +318,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
</select> </select>
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
BG color BG color
</label> </label>
<input <input
@ -329,7 +329,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
/> />
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Text color Text color
</label> </label>
<input <input

View File

@ -34,7 +34,7 @@ const TooltipSettingsSectionCompact: React.FC<
return ( return (
<div className='space-y-2'> <div className='space-y-2'>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Icon Icon
</label> </label>
<select <select
@ -56,7 +56,7 @@ const TooltipSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Tooltip title Tooltip title
</label> </label>
<input <input
@ -67,7 +67,7 @@ const TooltipSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Tooltip text Tooltip text
</label> </label>
<textarea <textarea
@ -79,7 +79,7 @@ const TooltipSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Title font family Title font family
</label> </label>
<select <select
@ -99,7 +99,7 @@ const TooltipSettingsSectionCompact: React.FC<
</div> </div>
<div> <div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'> <label className='mb-1 block text-[11px] font-semibold text-white/80'>
Text font family Text font family
</label> </label>
<select <select

View File

@ -53,6 +53,12 @@ export interface EffectsSettingsFormValues {
activeScale?: string; activeScale?: string;
activeOpacity?: string; activeOpacity?: string;
activeBackgroundColor?: string; activeBackgroundColor?: string;
// Slide transition override (Gallery/Carousel only)
// These override page transition settings for this element's slides
slideTransitionType?: string;
slideTransitionDurationMs?: string;
slideTransitionEasing?: string;
slideTransitionOverlayColor?: string;
} }
/** /**

View File

@ -1,9 +1,14 @@
/** /**
* PreviousBackgroundOverlay Component * PreviousBackgroundOverlay Component
* *
* Renders the previous page background during page transitions. * Shows the previous page background IMAGE during page transitions
* Shows during loading and crossfade, with optional fade-out animation. * while the new background is loading.
* Used by both CanvasBackground (constructor) and RuntimePresentation. *
* Renders when: isSwitching=true AND isNewBgReady=false
* Hides instantly when new background is ready.
*
* Note: Video backgrounds are NOT rendered here. During transitions,
* video is covered by TransitionPreviewOverlay at z-50.
*/ */
import React from 'react'; import React from 'react';
@ -11,55 +16,44 @@ import React from 'react';
interface PreviousBackgroundOverlayProps { interface PreviousBackgroundOverlayProps {
/** Previous background image URL */ /** Previous background image URL */
imageUrl?: string; imageUrl?: string;
/** Previous background video URL */ /** Previous background video URL (kept for interface compatibility, not rendered) */
videoUrl?: string; videoUrl?: string;
/** Whether page is currently switching */ /** Whether page is currently switching */
isSwitching?: boolean; isSwitching?: boolean;
/** Whether new background is ready */ /** Whether new background is ready */
isNewBgReady?: boolean; isNewBgReady?: boolean;
/** Whether fade animation is in progress */ /** Whether to pause video playback (kept for interface compatibility) */
isFadingIn?: boolean; paused?: boolean;
/** Additional CSS classes */ /** Additional CSS classes */
className?: string; className?: string;
/** Fade duration - DEPRECATED, kept for interface compat */
fadeDuration?: number;
} }
const PreviousBackgroundOverlay: React.FC<PreviousBackgroundOverlayProps> = ({ const PreviousBackgroundOverlay: React.FC<PreviousBackgroundOverlayProps> = ({
imageUrl, imageUrl,
videoUrl, // videoUrl - not used, see docstring
isSwitching = false, isSwitching = false,
isNewBgReady = false, isNewBgReady = false,
isFadingIn = false, // paused - not used, see docstring
className = '', className = '',
// fadeDuration - deprecated, not used
}) => { }) => {
// Show during loading (isSwitching && !isNewBgReady) OR during crossfade (isFadingIn) // Simple render logic: show while switching AND new bg not ready
const shouldShow = isFadingIn || (isSwitching && !isNewBgReady); const shouldRender = isSwitching && !isNewBgReady && !!imageUrl;
if (!shouldShow) return null; if (!shouldRender) return null;
return ( return (
<> <div
{imageUrl && ( className={`pointer-events-none absolute inset-0 z-2 ${className}`}
<div style={{
className={`pointer-events-none absolute inset-0 z-0 ${isFadingIn ? 'animate-crossfade-out' : ''} ${className}`} backgroundImage: `url("${imageUrl}")`,
style={{ backgroundSize: 'contain',
backgroundImage: `url("${imageUrl}")`, backgroundPosition: 'center',
backgroundSize: 'contain', backgroundRepeat: 'no-repeat',
backgroundPosition: 'center', }}
backgroundRepeat: 'no-repeat', />
}}
/>
)}
{videoUrl && (
<video
className={`absolute inset-0 z-0 h-full w-full object-contain pointer-events-none ${isFadingIn ? 'animate-crossfade-out' : ''} ${className}`}
src={videoUrl}
autoPlay
loop
muted
playsInline
/>
)}
</>
); );
}; };

View File

@ -15,9 +15,9 @@ import {
hasAnyEffects, hasAnyEffects,
type ElementEffectProperties, type ElementEffectProperties,
} from '../lib/elementEffects'; } from '../lib/elementEffects';
import { isNavigationElementType } from '../lib/elementDefaults';
import { isBackNavigation } from '../lib/navigationHelpers';
import type { CanvasElement } from '../types/constructor'; import type { CanvasElement } from '../types/constructor';
import type { ResolvedTransitionSettings } from '../types/transition';
import type { PreloadCacheProvider } from '../hooks/video';
interface RuntimeElementProps { interface RuntimeElementProps {
element: CanvasElement; element: CanvasElement;
@ -28,8 +28,10 @@ interface RuntimeElementProps {
onGalleryCardClick?: (cardIndex: number) => void; onGalleryCardClick?: (cardIndex: number) => void;
/** Letterbox styles for constraining fullscreen elements to canvas bounds */ /** Letterbox styles for constraining fullscreen elements to canvas bounds */
letterboxStyles?: React.CSSProperties; letterboxStyles?: React.CSSProperties;
/** Whether forward navigation is disabled (neighbor pages not yet preloaded) */ /** Page transition settings (for slide transition cascade in carousel/gallery) */
isForwardNavDisabled?: boolean; pageTransitionSettings?: ResolvedTransitionSettings;
/** Preload cache provider for video elements */
preloadCache?: PreloadCacheProvider;
} }
// Clamp position to canvas bounds (0-100%) // Clamp position to canvas bounds (0-100%)
@ -42,7 +44,8 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
resolveUrl, resolveUrl,
onGalleryCardClick, onGalleryCardClick,
letterboxStyles, letterboxStyles,
isForwardNavDisabled = false, pageTransitionSettings,
preloadCache,
}) => { }) => {
// Clamp coordinates to canvas bounds // Clamp coordinates to canvas bounds
const xPercent = clamp(element.xPercent ?? 50, 0, 100); const xPercent = clamp(element.xPercent ?? 50, 0, 100);
@ -102,14 +105,6 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
positionStyle = { ...positionStyle, ...animationStyle }; positionStyle = { ...positionStyle, ...animationStyle };
} }
// Compute disabled state for navigation elements
// Forward navigation disabled when neighbor pages not preloaded
// Back navigation always enabled (previous pages are already visited)
const isDisabled =
isNavigationElementType(element.type) &&
!isBackNavigation(element) &&
isForwardNavDisabled;
return ( return (
<div <div
className='absolute cursor-pointer' className='absolute cursor-pointer'
@ -123,7 +118,8 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
resolveUrl={resolveUrl} resolveUrl={resolveUrl}
onGalleryCardClick={onGalleryCardClick} onGalleryCardClick={onGalleryCardClick}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
isDisabled={isDisabled} pageTransitionSettings={pageTransitionSettings}
preloadCache={preloadCache}
/> />
</div> </div>
); );

View File

@ -14,6 +14,7 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { flushSync } from 'react-dom';
import { ToastContainer } from 'react-toastify'; import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import CardBox from './CardBox'; import CardBox from './CardBox';
@ -24,6 +25,8 @@ import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
import { BackdropPortalProvider } from './BackdropPortal'; import { BackdropPortalProvider } from './BackdropPortal';
import { RotatePrompt } from './RotatePrompt'; import { RotatePrompt } from './RotatePrompt';
import CanvasBackground from './Constructor/CanvasBackground'; import CanvasBackground from './Constructor/CanvasBackground';
import CanvasLoadingSpinner from './CanvasLoadingSpinner';
import TransitionBlackOverlay from './TransitionBlackOverlay';
import { useCanvasScale } from '../hooks/useCanvasScale'; import { useCanvasScale } from '../hooks/useCanvasScale';
import { CANVAS_CONFIG } from '../config/canvas.config'; import { CANVAS_CONFIG } from '../config/canvas.config';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
@ -36,12 +39,12 @@ import {
extractPageLinksOnly, extractPageLinksOnly,
extractElementsForPages, extractElementsForPages,
} from '../lib/extractPageLinks'; } from '../lib/extractPageLinks';
import { usePageSwitch } from '../hooks/usePageSwitch'; import { usePageNavigationState } from '../hooks/usePageNavigationState';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
import { useBackgroundTransition } from '../hooks/useBackgroundTransition'; import { useNetworkAware } from '../hooks/useNetworkAware';
import { useBackgroundUrls } from '../hooks/useBackgroundUrls';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; import { resolveAssetPlaybackUrl } from '../lib/assetUrl';
import { isSafari, scheduleAfterPaint } from '../lib/browserUtils'; import { downloadManager } from '../lib/offline/DownloadManager';
import { isSafari } from '../lib/browserUtils';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { import {
resolveNavigationTarget, resolveNavigationTarget,
@ -49,8 +52,20 @@ import {
isBackNavigation, isBackNavigation,
isNavigationType, isNavigationType,
} from '../lib/navigationHelpers'; } from '../lib/navigationHelpers';
import { useTransitionSettings } from '../hooks/useTransitionSettings';
import { useAppSelector, useAppDispatch } from '../stores/hooks';
import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
import {
fetchByProjectAndEnv as fetchProjectTransitionSettings,
selectByProjectAndEnv as selectProjectTransitionSettings,
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
import type { TransitionPhase } from '../types/presentation'; import type { TransitionPhase } from '../types/presentation';
import type { CanvasElement } from '../types/constructor'; import type { CanvasElement } from '../types/constructor';
import type { ElementTransitionSettings } from '../types/transition';
import {
entityToProjectSettings,
extractElementTransitionSettings,
} from '../types/transition';
interface RuntimePresentationProps { interface RuntimePresentationProps {
projectSlug: string; projectSlug: string;
@ -61,7 +76,13 @@ export default function RuntimePresentation({
projectSlug, projectSlug,
environment, environment,
}: RuntimePresentationProps) { }: RuntimePresentationProps) {
const dispatch = useAppDispatch();
const globalTransitionDefaults = useAppSelector(
(state) => state.global_transition_defaults.data,
);
// Use shared hook for loading project and pages data // Use shared hook for loading project and pages data
// Note: We can't fetch project transition settings until we have the project ID
const { project, pages, isLoading, error, initialPageId } = usePageDataLoader( const { project, pages, isLoading, error, initialPageId } = usePageDataLoader(
{ {
projectSlug, projectSlug,
@ -73,6 +94,44 @@ export default function RuntimePresentation({
}, },
); );
// Fetch global transition defaults on mount (public endpoint, no auth needed)
useEffect(() => {
dispatch(fetchGlobalTransitionDefaults());
}, [dispatch]);
// Fetch project transition settings when project is loaded
useEffect(() => {
if (project?.id) {
dispatch(
fetchProjectTransitionSettings({ projectId: project.id, environment }),
);
}
}, [dispatch, project?.id, environment]);
// Select project transition settings from store (environment-aware)
const projectTransitionSettingsEntity = useAppSelector((state) =>
project?.id
? selectProjectTransitionSettings(state, project.id, environment)
: undefined,
);
const projectTransitionSettings = useMemo(
() => entityToProjectSettings(projectTransitionSettingsEntity),
[projectTransitionSettingsEntity],
);
// Track current element's transition settings (set when navigation is triggered)
const [
currentElementTransitionSettings,
setCurrentElementTransitionSettings,
] = useState<ElementTransitionSettings | null>(null);
// Resolve transition settings using the cascade: Element → Project → Global
const transitionSettings = useTransitionSettings({
globalDefaults: globalTransitionDefaults,
projectSettings: projectTransitionSettings,
elementSettings: currentElementTransitionSettings,
});
// Resolve project assets (favicon, og_image, logo) to presigned URLs // Resolve project assets (favicon, og_image, logo) to presigned URLs
const { faviconUrl, ogImageUrl } = useProjectAssets(project); const { faviconUrl, ogImageUrl } = useProjectAssets(project);
@ -105,29 +164,23 @@ export default function RuntimePresentation({
designHeight: currentPage?.design_height ?? undefined, designHeight: currentPage?.design_height ?? undefined,
}); });
// Network-aware transitions: skip video on slow networks, use CSS fade instead
const { shouldUseVideoTransitions, networkInfo } = useNetworkAware();
const [transitionPreview, setTransitionPreview] = useState<{ const [transitionPreview, setTransitionPreview] = useState<{
targetPageId: string; targetPageId: string;
videoUrl: string; videoUrl: string;
storageKey: string; storageKey: string;
isBack: boolean; isBack: boolean;
reverseVideoUrl?: string; reverseVideoUrl?: string;
reverseStorageKey?: string;
} | null>(null); } | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
// Track when transition video has completed but we're waiting for background to load
const [pendingTransitionComplete, setPendingTransitionComplete] =
useState(false);
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{ const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
element: CanvasElement; element: CanvasElement;
initialIndex: number; initialIndex: number;
} | null>(null); } | null>(null);
// Safari Black Flash Prevention (video transitions only):
// Track the last successfully displayed background to use as a "snapshot" layer.
// Only shown during video transitions to prevent black flashes.
// NOT shown during crossfade navigation (would interfere with smooth animation).
const [lastKnownBgUrl, setLastKnownBgUrl] = useState<string>('');
const transitionVideoRef = useRef<HTMLVideoElement>(null); const transitionVideoRef = useRef<HTMLVideoElement>(null);
const lastInitializedPageIdRef = useRef<string | null>(null); const lastInitializedPageIdRef = useRef<string | null>(null);
@ -175,6 +228,9 @@ export default function RuntimePresentation({
}, [pages, pageLinks, selectedPageId]); }, [pages, pageLinks, selectedPageId]);
// Initialize preload orchestrator with transformed data // Initialize preload orchestrator with transformed data
// STREAM-FIRST: Preloads current page + transition videos only
// Online: Videos stream on-demand, cache after playback (no bandwidth competition)
// Offline: Assets already fully downloaded via useOfflineMode.startDownload()
const preloadOrchestrator = usePreloadOrchestrator({ const preloadOrchestrator = usePreloadOrchestrator({
pages, pages,
pageLinks, pageLinks,
@ -184,8 +240,15 @@ export default function RuntimePresentation({
enabled: !isLoading && !error, enabled: !isLoading && !error,
}); });
// Initialize page switch hook for smooth background transitions // Selected page - moved early for easier access
const pageSwitch = usePageSwitch({ const selectedPage = useMemo(
() => pages.find((p) => p.id === selectedPageId) || null,
[pages, selectedPageId],
);
// Unified page navigation state machine (replaces 6+ separate hooks)
// Uses useReducer for atomic state transitions, preventing race conditions
const navState = usePageNavigationState({
preloadCache: preloadOrchestrator preloadCache: preloadOrchestrator
? { ? {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
@ -193,10 +256,37 @@ export default function RuntimePresentation({
preloadedUrls: preloadOrchestrator.preloadedUrls, preloadedUrls: preloadOrchestrator.preloadedUrls,
} }
: undefined, : undefined,
transitionSettings,
}); });
// Destructure for convenience (matches previous hook interfaces)
// showElements/showSpinner are derived from the unified state machine phase:
// - showElements: true when phase is 'idle' or 'fading_in'
// - showSpinner: true when phase is 'preparing', 'loading_bg', or 'transition_done'
const {
currentImageUrl: navCurrentBgImageUrl,
currentVideoUrl: navCurrentBgVideoUrl,
previousImageUrl: navPreviousBgImageUrl,
previousVideoUrl: navPreviousBgVideoUrl,
isSwitching: navIsSwitching,
isNewBgReady: navIsNewBgReady,
pendingTransitionComplete,
isFadingIn,
showElements: navShowElements,
showSpinner: navShowSpinner,
showTransitionVideo,
transitionStyle,
lastKnownBgUrl,
onBackgroundReady: navOnBackgroundReady,
onVideoBufferStateChange,
onTransitionEnded,
navigateToPage: navNavigateToPage,
resetToIdle: navResetToIdle,
startTransition,
} = navState;
// Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern) // Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern)
const { isBuffering, phase: transitionPhase } = useTransitionPlayback({ const { isBuffering, isVideoReady, phase: transitionPhase } = useTransitionPlayback({
videoRef: transitionVideoRef, videoRef: transitionVideoRef,
transition: transitionPreview transition: transitionPreview
? { ? {
@ -204,33 +294,39 @@ export default function RuntimePresentation({
storageKey: transitionPreview.storageKey, storageKey: transitionPreview.storageKey,
reverseMode: transitionPreview.reverseVideoUrl ? 'separate' : 'none', reverseMode: transitionPreview.reverseVideoUrl ? 'separate' : 'none',
reverseVideoUrl: transitionPreview.reverseVideoUrl, reverseVideoUrl: transitionPreview.reverseVideoUrl,
reverseStorageKey: transitionPreview.reverseStorageKey, // Raw path for cache lookup
targetPageId: transitionPreview.targetPageId, targetPageId: transitionPreview.targetPageId,
displayName: 'Transition', displayName: 'Transition',
isBack: transitionPreview.isBack, isBack: transitionPreview.isBack,
} }
: null, : null,
onComplete: async (targetPageId, isBack) => { onComplete: async (targetPageId, isBack) => {
// Resume background downloads now that transition is complete
downloadManager.resumeAll();
if (targetPageId) { if (targetPageId) {
const targetPage = pages.find((p) => p.id === targetPageId); const targetPage = pages.find((p) => p.id === targetPageId);
// Mark this page as initialized to prevent redundant effect calls // Mark this page as initialized to prevent redundant effect calls
lastInitializedPageIdRef.current = targetPageId; lastInitializedPageIdRef.current = targetPageId;
// Use shared hook to resolve blob URLs and switch page // Signal that transition video has ended
await pageSwitch.switchToPage(targetPage, () => { // State machine transitions to 'transition_done', waiting for background
// Use applyPageSelection for proper history management (pops on back) onTransitionEnded();
applyPageSelection(targetPageId, isBack ?? false); // DON'T close transitionPreview here - it stays visible until background is ready
// The useEffect below will close it when pendingTransitionComplete becomes false
// Navigate to target page - state machine handles ready state
await navNavigateToPage(targetPage, {
hasTransition: false, // Already played
isBack: isBack ?? false,
onSwitched: () => {
applyPageSelection(targetPageId, isBack ?? false);
},
}); });
setIsBackgroundReady(false);
// Video transition completed - last frame shows new page background
// Signal that we're waiting for background to load before removing overlay
// Overlay will be removed instantly (no fade) when isBackgroundReady becomes true
setPendingTransitionComplete(true);
} else { } else {
// No target page - clean up and remove overlay // No target page - clean up and remove overlay
const video = transitionVideoRef.current; const video = transitionVideoRef.current;
video?.removeAttribute('src'); video?.removeAttribute('src');
video?.load(); video?.load();
setTransitionPreview(null); setTransitionPreview(null);
setPendingTransitionComplete(false); navResetToIdle();
} }
}, },
features: { features: {
@ -243,22 +339,30 @@ export default function RuntimePresentation({
preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(), preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(),
getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl, getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl,
getReadyBlobUrl: preloadOrchestrator?.getReadyBlobUrl, getReadyBlobUrl: preloadOrchestrator?.getReadyBlobUrl,
getReadyBlob: preloadOrchestrator?.getReadyBlob,
}, },
}); });
// Use shared background transition hook for crossfade effects // Sync transition video buffering state with navigation state machine
// NOTE: fadeOut config is NOT used for video transitions. // This enables unified showSpinner logic in the state machine
// Video transitions end instantly (last frame = new page, then overlay removed). useEffect(() => {
// fadeIn is used for non-video navigation (crossfade 500ms). const isTransitionBuffering = Boolean(transitionPreview) && isBuffering;
// hasActiveTransition includes pendingTransitionComplete to prevent crossfade onVideoBufferStateChange(isTransitionBuffering);
// during the video-to-background handoff phase. }, [transitionPreview, isBuffering, onVideoBufferStateChange]);
const { isFadingIn, resetFadeIn } = useBackgroundTransition({
pageSwitch, // Clean up transition preview when state machine says video overlay should be hidden
fadeIn: { // showTransitionVideo is true during 'transitioning', 'transition_done', and 'fading_in' phases
hasActiveTransition: // During 'fading_in', the overlay fades out (isFadingOut=true), then removed when phase goes to 'idle'
Boolean(transitionPreview) || pendingTransitionComplete, useEffect(() => {
}, if (transitionPreview && !showTransitionVideo) {
}); setTransitionPreview(null);
}
}, [transitionPreview, showTransitionVideo]);
// Reset navigation state when starting a new transition
const resetFadeIn = useCallback(() => {
navResetToIdle();
}, [navResetToIdle]);
const toggleFullscreen = useCallback(async () => { const toggleFullscreen = useCallback(async () => {
try { try {
@ -287,11 +391,6 @@ export default function RuntimePresentation({
document.removeEventListener('fullscreenchange', handleFullscreenChange); document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []); }, []);
const selectedPage = useMemo(
() => pages.find((p) => p.id === selectedPageId) || null,
[pages, selectedPageId],
);
const pageElements = useMemo(() => { const pageElements = useMemo(() => {
if (!selectedPage) return []; if (!selectedPage) return [];
@ -313,84 +412,27 @@ export default function RuntimePresentation({
useEffect(() => { useEffect(() => {
if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) { if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) {
// Only initialize when backgrounds are empty (initial load) // Only initialize when backgrounds are empty (initial load)
// navigateToPage handles subsequent navigation by calling switchToPage directly if (!navCurrentBgImageUrl && !navCurrentBgVideoUrl) {
if (!pageSwitch.currentBgImageUrl && !pageSwitch.currentBgVideoUrl) {
lastInitializedPageIdRef.current = selectedPage.id; lastInitializedPageIdRef.current = selectedPage.id;
pageSwitch.switchToPage(selectedPage); navNavigateToPage(selectedPage);
} }
} }
}, [ }, [
selectedPage, selectedPage,
pageSwitch.currentBgImageUrl, navCurrentBgImageUrl,
pageSwitch.currentBgVideoUrl, navCurrentBgVideoUrl,
pageSwitch.switchToPage, navNavigateToPage,
]); ]);
// Handle background ready state for pages without any background // Video transition overlay removal - clears when elements should show
// When phase becomes 'idle' or 'fading_in' (navShowElements=true),
// the transition preview is no longer needed and can be cleared
useEffect(() => { useEffect(() => {
// Only mark ready immediately if there's no background media at all. if (navShowElements && transitionPreview) {
// For pages with image or video, CanvasBackground will call onBackgroundReady // Clear transition preview - overlay will be removed
// after the media's first frame is painted (using scheduleAfterPaint or requestVideoFrameCallback). setTransitionPreview(null);
if (
!selectedPage?.background_image_url &&
!selectedPage?.background_video_url
) {
setIsBackgroundReady(true);
} }
}, [selectedPage?.background_image_url, selectedPage?.background_video_url]); }, [navShowElements, transitionPreview]);
// Video transition overlay removal - instant (no fade) when background is ready
// Uses scheduleAfterPaint to ensure browser has painted the new background before removing overlay
// Note: Double RAF doesn't work in Safari (nested RAFs can execute in same frame)
// CRITICAL: Must wait for BOTH isBackgroundReady AND pageSwitch.isNewBgReady
// - isBackgroundReady: RuntimePresentation state (image onLoad or immediate for video pages)
// - pageSwitch.isNewBgReady: Controls PreviousBackgroundOverlay visibility
// If we only check isBackgroundReady, PreviousBackgroundOverlay may still be showing
useEffect(() => {
if (
pendingTransitionComplete &&
isBackgroundReady &&
pageSwitch.isNewBgReady
) {
// Wait for paint cycle to complete before removing overlay
// scheduleAfterPaint handles Safari's RAF quirks automatically
scheduleAfterPaint(() => {
// CRITICAL: Remove overlay from DOM FIRST, then clear video src
// If we clear src before removing overlay, Safari shows black frame
// because video.removeAttribute('src') immediately clears the frame
setTransitionPreview(null);
setPendingTransitionComplete(false);
// Clear previous background now that transition is complete
// This resets isSwitching state for next navigation
pageSwitch.clearPreviousBackground();
// Clear video src AFTER overlay is removed from DOM
// Use another scheduleAfterPaint to ensure React has unmounted the overlay
scheduleAfterPaint(() => {
const video = transitionVideoRef.current;
if (video) {
video.removeAttribute('src');
video.load();
}
});
});
}
}, [
pendingTransitionComplete,
isBackgroundReady,
pageSwitch.isNewBgReady,
pageSwitch.clearPreviousBackground,
]);
// Safari Black Flash Prevention (video transitions only):
// Update lastKnownBgUrl whenever we have a valid background image.
// This ensures snapshot is always ready before transitions start.
useEffect(() => {
if (pageSwitch.currentBgImageUrl) {
setLastKnownBgUrl(pageSwitch.currentBgImageUrl);
}
}, [pageSwitch.currentBgImageUrl]);
const navigateToPage = useCallback( const navigateToPage = useCallback(
async ( async (
@ -402,10 +444,22 @@ export default function RuntimePresentation({
const targetPage = pages.find((p) => p.id === targetPageId); const targetPage = pages.find((p) => p.id === targetPageId);
if (!targetPage) return; if (!targetPage) return;
if (transitionVideoUrl) { // Check if video is already cached (use video even on slow network if cached)
const isTransitionCached =
transitionVideoUrl && preloadOrchestrator?.getReadyBlobUrl(transitionVideoUrl);
// Use video if: has transition AND (cached OR good network)
const useVideoTransition =
transitionVideoUrl && (isTransitionCached || shouldUseVideoTransitions);
if (useVideoTransition) {
// Reset states from previous transition/navigation // Reset states from previous transition/navigation
resetFadeIn(); resetFadeIn();
setPendingTransitionComplete(false); // Pause background downloads to give transition video exclusive bandwidth
downloadManager.pauseAll();
// Signal navigation state machine that video transition is starting
// This sets phase to 'transitioning' so spinner shows during buffering
startTransition(targetPageId, isBack);
// Play transition using useTransitionPlayback hook // Play transition using useTransitionPlayback hook
setTransitionPreview({ setTransitionPreview({
targetPageId, targetPageId,
@ -415,32 +469,64 @@ export default function RuntimePresentation({
reverseVideoUrl: reverseVideoUrl reverseVideoUrl: reverseVideoUrl
? resolveAssetPlaybackUrl(reverseVideoUrl) ? resolveAssetPlaybackUrl(reverseVideoUrl)
: undefined, : undefined,
reverseStorageKey: reverseVideoUrl, // Raw storage path for reverse video cache lookup
}); });
} else { } else {
// Direct navigation with crossfade effect: // Direct navigation with fade-from-black effect:
// useBackgroundTransition detects switching and applies animation classes // Page switches instantly, black overlay fades out to reveal new page
// - New page gets animate-crossfade-in (0 → 1)
// - Previous background gets animate-crossfade-out (1 → 0)
setIsBackgroundReady(false);
// Mark this page as initialized to prevent redundant effect calls // Mark this page as initialized to prevent redundant effect calls
lastInitializedPageIdRef.current = targetPageId; lastInitializedPageIdRef.current = targetPageId;
await pageSwitch.switchToPage(targetPage, () => { // Log when skipping video due to slow network
// Use applyPageSelection for proper history management (pops on back) if (transitionVideoUrl && !shouldUseVideoTransitions) {
applyPageSelection(targetPageId, isBack); logger.info(
'[NAVIGATION] Skipping video transition due to slow network, downloading in background',
{
effectiveType: networkInfo.effectiveType,
downlink: networkInfo.downlink,
rtt: networkInfo.rtt,
},
);
// Start background download of transition video for future use (low priority)
downloadManager.addJob({
assetId: `transition-bg-${transitionVideoUrl}`,
projectId: 'transition-preload',
url: resolveAssetPlaybackUrl(transitionVideoUrl),
filename: transitionVideoUrl.split('/').pop() || 'transition.mp4',
variantType: 'original',
assetType: 'video',
priority: 10, // Low priority - background preload
storageKey: transitionVideoUrl,
});
}
await navNavigateToPage(targetPage, {
hasTransition: false,
isBack,
onSwitched: () => {
applyPageSelection(targetPageId, isBack);
},
}); });
} }
}, },
[pages, pageSwitch, resetFadeIn, applyPageSelection], [
pages,
navNavigateToPage,
resetFadeIn,
applyPageSelection,
startTransition,
shouldUseVideoTransitions,
networkInfo,
preloadOrchestrator,
],
); );
// Compute whether all neighbor backgrounds are ready for instant navigation // Page loading state from unified navigation state machine
const areNeighborBackgroundsReady = // navShowSpinner: true when phase is 'preparing', 'loading_bg', or 'transition_done'
preloadOrchestrator?.areNeighborBackgroundsReady ?? true; // navShowElements: true when phase is 'idle' or 'fading_in'
const areTransitionsReady = preloadOrchestrator?.areTransitionsReady ?? true;
// Compute disabled state for forward navigation elements
// DISABLED: Allow navigation even if neighbors not preloaded
const isForwardNavDisabled = false && !areNeighborBackgroundsReady;
const handleElementClick = useCallback( const handleElementClick = useCallback(
(element: CanvasElement) => { (element: CanvasElement) => {
@ -451,18 +537,6 @@ export default function RuntimePresentation({
return; return;
} }
// DISABLED: Block forward navigation if neighbor backgrounds not yet preloaded
// Back navigation is always allowed (previous pages are already visited)
if (
false &&
isNavigationType(element.type) &&
!isBackNavigation(element) &&
!areNeighborBackgroundsReady
) {
logger.info('Navigation blocked - neighbors not preloaded');
return;
}
// Get navigation context from hook for history-based back navigation // Get navigation context from hook for history-based back navigation
const navContext = getNavigationContext(); const navContext = getNavigationContext();
@ -482,6 +556,21 @@ export default function RuntimePresentation({
}); });
if (navTarget) { if (navTarget) {
// Extract element transition settings for CSS-based transitions
// For back navigation, use navTarget's settings (the forward element that brought us here)
// For forward navigation, use the clicked element's settings
const elementTransitionSource = isBackNavigation(element)
? navTarget
: element;
const elementSettings = extractElementTransitionSettings(
elementTransitionSource,
);
// Use flushSync to ensure state is updated synchronously before transition starts
// Without this, React's async state batching causes the transition to use OLD settings
flushSync(() => {
setCurrentElementTransitionSettings(elementSettings);
});
navigateToPage( navigateToPage(
navTarget.pageId, navTarget.pageId,
navTarget.transitionVideoUrl, navTarget.transitionVideoUrl,
@ -496,7 +585,7 @@ export default function RuntimePresentation({
transitionPhase, transitionPhase,
isBuffering, isBuffering,
getNavigationContext, getNavigationContext,
areNeighborBackgroundsReady, setCurrentElementTransitionSettings,
], ],
); );
@ -528,15 +617,9 @@ export default function RuntimePresentation({
[preloadOrchestrator], [preloadOrchestrator],
); );
// Unified background URL resolution via shared hook (same as constructor) // Background URLs come directly from navigation state (already resolved)
// No localPaths needed since RuntimePresentation has no editing mode const backgroundImageUrl = navCurrentBgImageUrl;
const { const backgroundVideoUrl = navCurrentBgVideoUrl;
backgroundImageSrc: backgroundImageUrl,
backgroundVideoSrc: backgroundVideoUrl,
} = useBackgroundUrls({
pageSwitch,
resolveUrl: resolveUrlWithBlob,
});
// Background video playback settings from selected page // Background video playback settings from selected page
const videoAutoplay = selectedPage?.background_video_autoplay ?? true; const videoAutoplay = selectedPage?.background_video_autoplay ?? true;
@ -664,7 +747,6 @@ export default function RuntimePresentation({
<BackdropPortalProvider> <BackdropPortalProvider>
{/* Safari Black Flash Prevention (video transitions only): {/* Safari Black Flash Prevention (video transitions only):
Persistent snapshot layer shown ONLY during video transitions. Persistent snapshot layer shown ONLY during video transitions.
NOT shown during crossfade navigation (would interfere with animation).
z-[1] keeps it behind backgrounds (z-5) but above the black container. */} z-[1] keeps it behind backgrounds (z-5) but above the black container. */}
{lastKnownBgUrl && {lastKnownBgUrl &&
isSafari() && isSafari() &&
@ -681,76 +763,113 @@ export default function RuntimePresentation({
)} )}
{/* Page background wrapper - z-5 keeps it BELOW carousel slide (z-10). {/* Page background wrapper - z-5 keeps it BELOW carousel slide (z-10).
Fades in for non-transition navigation. Uses shared CanvasBackground component Uses shared CanvasBackground component for single source of truth with constructor.
for single source of truth with constructor (same transitions, same structure). */} Previous background overlay shows during loading.
Black overlay for fade effect is rendered separately at z-[100]. */}
<div <div
data-testid='page-background-wrapper' data-testid='page-background-wrapper'
className={`absolute inset-0 z-5 ${isFadingIn ? 'animate-crossfade-in' : ''}`} className='absolute inset-0 z-5'
> >
<CanvasBackground <CanvasBackground
backgroundImageUrl={backgroundImageUrl} backgroundImageUrl={backgroundImageUrl}
backgroundVideoUrl={backgroundVideoUrl} backgroundVideoUrl={backgroundVideoUrl}
previousBgImageUrl={pageSwitch.previousBgImageUrl} previousBgImageUrl={navPreviousBgImageUrl}
previousBgVideoUrl={pageSwitch.previousBgVideoUrl} previousBgVideoUrl={navPreviousBgVideoUrl}
isSwitching={pageSwitch.isSwitching} isSwitching={navIsSwitching}
isNewBgReady={pageSwitch.isNewBgReady} isNewBgReady={navIsNewBgReady}
isFadingIn={isFadingIn} onBackgroundReady={navOnBackgroundReady}
onBackgroundReady={() => { onVideoBufferStateChange={onVideoBufferStateChange}
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
videoAutoplay={videoAutoplay} videoAutoplay={videoAutoplay}
videoLoop={videoLoop} videoLoop={videoLoop}
videoMuted={soundControl.isMuted} videoMuted={soundControl.isMuted}
videoStartTime={videoStartTime} videoStartTime={videoStartTime}
videoEndTime={videoEndTime} videoEndTime={videoEndTime}
videoStoragePath={selectedPage?.background_video_url} videoStoragePath={selectedPage?.background_video_url}
pauseVideo={
Boolean(transitionPreview) ||
pendingTransitionComplete ||
navIsSwitching
}
/> />
</div> </div>
{/* End page background wrapper */} {/* End page background wrapper */}
{/* Page loading spinner - from unified navigation state machine.
navShowSpinner is true when:
- Phase is 'preparing', 'loading_bg', 'transition_done', OR
- Video transition is active but buffering
Skip when video transition overlay is active - it has its own spinner. */}
{navShowSpinner && !transitionPreview && (
<CanvasLoadingSpinner isVisible={true} zIndex={100} />
)}
{/* Page elements wrapper - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45). {/* Page elements wrapper - z-[46] keeps it ABOVE carousel slide (z-10) AND carousel controls (z-45).
UI controls (z-50) remain on top. UI controls (z-50) remain on top.
Fades in together with background. */} No fade animation - elements switch instantly behind the black overlay.
<div Shows when phase is 'idle' or 'fading_in' (navShowElements). */}
data-testid='page-elements-wrapper' {navShowElements && (
className={`absolute inset-0 z-[46] ${isFadingIn ? 'animate-crossfade-in' : ''}`} <div
> data-testid='page-elements-wrapper'
{pageElements.map((element: CanvasElement) => ( className='absolute inset-0 z-[46]'
<RuntimeElement >
key={element.id} {pageElements.map((element: CanvasElement) => (
element={element} <RuntimeElement
onClick={() => handleElementClick(element)} key={element.id}
resolveUrl={resolveUrlWithBlob} element={element}
onGalleryCardClick={(cardIndex) => onClick={() => handleElementClick(element)}
handleGalleryCardClick(element, cardIndex) resolveUrl={resolveUrlWithBlob}
} onGalleryCardClick={(cardIndex) =>
letterboxStyles={letterboxStyles} handleGalleryCardClick(element, cardIndex)
isForwardNavDisabled={isForwardNavDisabled} }
/> letterboxStyles={letterboxStyles}
))} pageTransitionSettings={transitionSettings}
</div> preloadCache={
preloadOrchestrator
? {
getReadyBlob: preloadOrchestrator.getReadyBlob,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
}
: undefined
}
/>
))}
</div>
)}
{/* End page elements wrapper */} {/* End page elements wrapper */}
{/* Overlay for fade-through-color transition - z-[100] is ABOVE elements (z-[46]).
This covers the elements during page transition to hide the instant switch.
Only rendered for 'fade' type. */}
<TransitionBlackOverlay
isFadingIn={isFadingIn}
transitionType={transitionSettings.type}
transitionStyle={transitionStyle}
overlayColor={transitionSettings.overlayColor}
/>
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */} {/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */} {/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
{/* NO fade-out: video itself IS the transition (last frame = new page) */} {/* Fades out during 'fading_in' phase when background is ready */}
{/* Overlay removed instantly via setTransitionPreview(null) in onComplete */} {/* Overlay stays visible until fade completes (phase goes to 'idle') */}
{transitionPreview && ( {transitionPreview && showTransitionVideo && (
<TransitionPreviewOverlay <TransitionPreviewOverlay
videoKey={transitionPreview.videoUrl}
videoRef={transitionVideoRef} videoRef={transitionVideoRef}
isActive={true} isActive={true}
isBuffering={ isBuffering={
// Hide overlay until video first frame is painted: // Show spinner during buffering:
// - 'idle': React render cycle before hook effect runs // - 'idle': React render cycle before hook effect runs
// - 'preparing': Video loading/buffering // - 'preparing': Video loading/buffering
// - isBuffering: Waiting for first frame paint (from hook) // - isBuffering: Waiting for first frame or mid-playback buffering
transitionPhase === 'idle' || transitionPhase === 'idle' ||
transitionPhase === 'preparing' || transitionPhase === 'preparing' ||
isBuffering isBuffering
} }
isVideoReady={isVideoReady}
showSpinner={true}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
opacity={1} isFadingOut={isFadingIn}
fadeOutDuration={transitionSettings.durationMs}
/> />
)} )}
@ -800,6 +919,8 @@ export default function RuntimePresentation({
} }
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
isEditMode={false} isEditMode={false}
pageTransitionSettings={transitionSettings}
galleryElement={activeGalleryCarousel.element}
/> />
)} )}
</BackdropPortalProvider> </BackdropPortalProvider>

View File

@ -4,7 +4,11 @@ import {
mdiFileDocumentPlus, mdiFileDocumentPlus,
mdiSwapHorizontal, mdiSwapHorizontal,
mdiViewDashboard, mdiViewDashboard,
mdiChevronDown,
mdiChevronUp,
mdiPencil,
} from '@mdi/js'; } from '@mdi/js';
import Icon from '@mdi/react';
import axios from 'axios'; import axios from 'axios';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
@ -17,15 +21,29 @@ import SectionMain from './SectionMain';
import SectionTitleLineWithButton from './SectionTitleLineWithButton'; import SectionTitleLineWithButton from './SectionTitleLineWithButton';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import { hasPermission } from '../helpers/userPermissions'; import { hasPermission } from '../helpers/userPermissions';
import { useAppSelector } from '../stores/hooks'; import { useAppSelector, useAppDispatch } from '../stores/hooks';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { sanitizeSlug, buildUniqueSlug, slugPattern } from '../lib/slugHelpers'; import { sanitizeSlug, buildUniqueSlug } from '../lib/slugHelpers';
import { import {
toRoutePath, toRoutePath,
compareRoutes, compareRoutes,
getProjectId, getProjectId,
getRows, getRows,
} from '../lib/tourFlowHelpers'; } from '../lib/tourFlowHelpers';
import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
import {
fetchByProjectAndEnv,
upsertByProjectAndEnv,
deleteByProjectAndEnv,
selectByProjectAndEnv,
selectIsLoading as selectTransitionSettingsLoading,
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
import type {
ProjectTransitionSettings,
TransitionType,
EasingFunction,
} from '../types/transition';
import { entityToProjectSettings } from '../types/transition';
type TourPage = { type TourPage = {
id: string; id: string;
@ -61,9 +79,27 @@ type ListEntry = {
parentPageId: string; parentPageId: string;
}; };
const TRANSITION_TYPES: { value: TransitionType | ''; label: string }[] = [
{ value: '', label: 'Use Global Default' },
{ value: 'fade', label: 'Fade' },
{ value: 'none', label: 'None (instant)' },
];
const EASING_OPTIONS: { value: EasingFunction | ''; label: string }[] = [
{ value: '', label: 'Use Global Default' },
{ value: 'ease-in-out', label: 'Ease In-Out' },
{ value: 'ease-in', label: 'Ease In' },
{ value: 'ease-out', label: 'Ease Out' },
{ value: 'linear', label: 'Linear' },
];
const TourFlowManager = () => { const TourFlowManager = () => {
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch();
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
const globalDefaults = useAppSelector(
(state) => state.global_transition_defaults.data,
);
const routeProjectId = useMemo(() => { const routeProjectId = useMemo(() => {
const value = router.query.projectId; const value = router.query.projectId;
@ -79,10 +115,45 @@ const TourFlowManager = () => {
const [isCreatingPage, setIsCreatingPage] = useState(false); const [isCreatingPage, setIsCreatingPage] = useState(false);
const [isCreatingTransition, setIsCreatingTransition] = useState(false); const [isCreatingTransition, setIsCreatingTransition] = useState(false);
const [isCreatePageModalActive, setIsCreatePageModalActive] = useState(false); const [isCreatePageModalActive, setIsCreatePageModalActive] = useState(false);
const [newPageSlug, setNewPageSlug] = useState(''); const [newPageName, setNewPageName] = useState('');
const [newPageSlugError, setNewPageSlugError] = useState('');
const [deletingId, setDeletingId] = useState(''); const [deletingId, setDeletingId] = useState('');
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
// Edit page modal state
const [isEditPageModalActive, setIsEditPageModalActive] = useState(false);
const [editingPageId, setEditingPageId] = useState('');
const [editPageName, setEditPageName] = useState('');
const [editPageNameError, setEditPageNameError] = useState('');
const [isSavingPageName, setIsSavingPageName] = useState(false);
// Use selector for current project's dev transition settings
const projectTransitionSettingsEntity = useAppSelector((state) =>
selectedProjectId
? selectByProjectAndEnv(state, selectedProjectId, 'dev')
: undefined,
);
const isTransitionSettingsLoading = useAppSelector((state) =>
selectedProjectId
? selectTransitionSettingsLoading(state, selectedProjectId, 'dev')
: false,
);
// Project transition settings state
const [isTransitionSettingsExpanded, setIsTransitionSettingsExpanded] =
useState(false);
// Convert entity to camelCase for local form state
const projectTransitionSettings = useMemo(
() => entityToProjectSettings(projectTransitionSettingsEntity),
[projectTransitionSettingsEntity],
);
const [localTransitionType, setLocalTransitionType] = useState<
TransitionType | ''
>('');
const [localDurationMs, setLocalDurationMs] = useState<number | ''>('');
const [localEasing, setLocalEasing] = useState<EasingFunction | ''>('');
const [localOverlayColor, setLocalOverlayColor] = useState<string>('');
const [isSavingTransitionSettings, setIsSavingTransitionSettings] =
useState(false);
const [transitionSaveSuccess, setTransitionSaveSuccess] = useState(false);
const canCreatePage = hasPermission(currentUser, 'CREATE_TOUR_PAGES'); const canCreatePage = hasPermission(currentUser, 'CREATE_TOUR_PAGES');
const canCreateTransition = hasPermission(currentUser, 'CREATE_TRANSITIONS'); const canCreateTransition = hasPermission(currentUser, 'CREATE_TRANSITIONS');
@ -161,6 +232,38 @@ const TourFlowManager = () => {
loadData(); loadData();
}, [loadData]); }, [loadData]);
// Fetch global transition defaults
useEffect(() => {
dispatch(fetchGlobalTransitionDefaults());
}, [dispatch]);
// Load project transition settings when project changes
useEffect(() => {
if (!selectedProjectId) {
setLocalTransitionType('');
setLocalDurationMs('');
setLocalEasing('');
setLocalOverlayColor('');
return;
}
// Dispatch fetch for dev environment settings
dispatch(
fetchByProjectAndEnv({
projectId: selectedProjectId,
environment: 'dev',
}),
);
}, [selectedProjectId, dispatch]);
// Sync local form state when store data changes
useEffect(() => {
setLocalTransitionType(projectTransitionSettings?.transitionType ?? '');
setLocalDurationMs(projectTransitionSettings?.durationMs ?? '');
setLocalEasing(projectTransitionSettings?.easing ?? '');
setLocalOverlayColor(projectTransitionSettings?.overlayColor ?? '');
}, [projectTransitionSettings]);
useEffect(() => { useEffect(() => {
if (!selectedProjectId) return; if (!selectedProjectId) return;
if (routeProjectId && selectedProjectId === routeProjectId) return; if (routeProjectId && selectedProjectId === routeProjectId) return;
@ -240,15 +343,25 @@ const TourFlowManager = () => {
[pages, targetEnvironment], [pages, targetEnvironment],
); );
const slugValidationError = useMemo(() => { const nameValidationError = useMemo(() => {
const slug = newPageSlug.trim(); const name = newPageName.trim();
if (!slug) return 'Slug is required.'; if (!name) return 'Page name is required.';
if (!slugPattern.test(slug)) if (name.length > 255) return 'Page name must be 255 characters or less.';
return 'Use lowercase letters, numbers, and hyphens only.';
if (pageSlugsInEnvironment.has(slug))
return 'This slug already exists in the selected environment.';
return ''; return '';
}, [newPageSlug, pageSlugsInEnvironment]); }, [newPageName]);
// Auto-generate unique slug from name
const generatedSlug = useMemo(() => {
const baseSlug = sanitizeSlug(newPageName) || 'page';
return buildUniqueSlug(baseSlug, pageSlugsInEnvironment);
}, [newPageName, pageSlugsInEnvironment]);
const editNameValidationError = useMemo(() => {
const name = editPageName.trim();
if (!name) return 'Page name is required.';
if (name.length > 255) return 'Page name must be 255 characters or less.';
return '';
}, [editPageName]);
const nextPageNumber = useMemo(() => pages.length + 1, [pages.length]); const nextPageNumber = useMemo(() => pages.length + 1, [pages.length]);
@ -310,24 +423,113 @@ const TourFlowManager = () => {
return; return;
} }
const suggestedSlug = buildUniqueSlug( const suggestedName = `Page ${nextPageNumber}`;
`page-${nextPageNumber}`, setNewPageName(suggestedName);
pageSlugsInEnvironment,
);
setNewPageSlug(suggestedSlug);
setNewPageSlugError('');
setIsCreatePageModalActive(true); setIsCreatePageModalActive(true);
}; };
const handleSlugChange = (value: string) => { const handleNameChange = (value: string) => {
setNewPageSlug(value); setNewPageName(value);
setNewPageSlugError('');
}; };
const closeCreatePageModal = () => { const closeCreatePageModal = () => {
setIsCreatePageModalActive(false); setIsCreatePageModalActive(false);
setNewPageSlug(''); setNewPageName('');
setNewPageSlugError(''); };
// Edit page modal handlers
const openEditPageModal = (event: React.MouseEvent, page: TourPage) => {
event.stopPropagation();
setEditingPageId(page.id);
setEditPageName(page.name || '');
setEditPageNameError('');
setIsEditPageModalActive(true);
};
const handleEditNameChange = (value: string) => {
setEditPageName(value);
setEditPageNameError('');
};
const closeEditPageModal = () => {
setIsEditPageModalActive(false);
setEditingPageId('');
setEditPageName('');
setEditPageNameError('');
};
const handleSavePageName = async () => {
if (editNameValidationError) {
setEditPageNameError(editNameValidationError);
return;
}
try {
setIsSavingPageName(true);
setEditPageNameError('');
// Fetch full page data from API to preserve all fields
// The backend's getFieldMapping converts missing fields to null
const pageResponse = await axios.get(`/tour_pages/${editingPageId}`);
const fullPageData = pageResponse.data;
if (!fullPageData) {
setEditPageNameError('Page not found');
return;
}
// Send all existing fields with only name changed
await axios.put(`/tour_pages/${editingPageId}`, {
id: editingPageId,
data: {
environment: fullPageData.environment,
source_key: fullPageData.source_key,
name: editPageName.trim(),
slug: fullPageData.slug,
sort_order: fullPageData.sort_order,
background_image_url: fullPageData.background_image_url,
background_video_url: fullPageData.background_video_url,
background_audio_url: fullPageData.background_audio_url,
background_loop: fullPageData.background_loop,
background_video_autoplay: fullPageData.background_video_autoplay,
background_video_loop: fullPageData.background_video_loop,
background_video_muted: fullPageData.background_video_muted,
background_video_start_time: fullPageData.background_video_start_time,
background_video_end_time: fullPageData.background_video_end_time,
design_width: fullPageData.design_width,
design_height: fullPageData.design_height,
requires_auth: fullPageData.requires_auth,
ui_schema_json: fullPageData.ui_schema_json,
},
});
// Update local state
setPages((prev) =>
prev.map((page) =>
page.id === editingPageId
? { ...page, name: editPageName.trim() }
: page,
),
);
closeEditPageModal();
toast.success('Page name updated');
} catch (error: unknown) {
const axiosError = error as {
response?: { data?: { message?: string } };
};
const message =
axiosError?.response?.data?.message ||
(error instanceof Error ? error.message : null) ||
'Failed to update page name.';
setEditPageNameError(message);
logger.error(
'Failed to update page name:',
error instanceof Error ? error : { error },
);
} finally {
setIsSavingPageName(false);
}
}; };
const handleCreatePage = async () => { const handleCreatePage = async () => {
@ -336,23 +538,22 @@ const TourFlowManager = () => {
return; return;
} }
const slug = newPageSlug.trim(); const name = newPageName.trim();
const validationError = slugValidationError || newPageSlugError;
if (validationError) { // Validate name
setNewPageSlugError(validationError); if (nameValidationError) {
return; return;
} }
try { try {
setIsCreatingPage(true); setIsCreatingPage(true);
setErrorMessage(''); setErrorMessage('');
setNewPageSlugError('');
const payload = { const payload = {
project: activeProjectId, project: activeProjectId,
environment: targetEnvironment, environment: targetEnvironment,
source_key: '', source_key: '',
name: `Page ${nextPageNumber}`, name,
slug, slug: generatedSlug,
sort_order: sort_order:
Math.max(...pages.map((item) => Number(item.sort_order || 0)), 0) + 1, Math.max(...pages.map((item) => Number(item.sort_order || 0)), 0) + 1,
background_image_url: '', background_image_url: '',
@ -364,8 +565,7 @@ const TourFlowManager = () => {
}; };
await axios.post('/tour_pages', { data: payload }); await axios.post('/tour_pages', { data: payload });
setIsCreatePageModalActive(false); closeCreatePageModal();
setNewPageSlug('');
await loadData(); await loadData();
} catch (error: unknown) { } catch (error: unknown) {
const axiosError = error as { const axiosError = error as {
@ -376,7 +576,6 @@ const TourFlowManager = () => {
(error instanceof Error ? error.message : null) || (error instanceof Error ? error.message : null) ||
'Failed to create page.'; 'Failed to create page.';
setErrorMessage(message); setErrorMessage(message);
setNewPageSlugError(message);
logger.error( logger.error(
'Failed to create page:', 'Failed to create page:',
error instanceof Error ? error : { error }, error instanceof Error ? error : { error },
@ -391,6 +590,57 @@ const TourFlowManager = () => {
toast.info('Transitions are configured directly on navigation elements.'); toast.info('Transitions are configured directly on navigation elements.');
}; };
const handleSaveTransitionSettings = async () => {
if (!selectedProjectId) return;
setIsSavingTransitionSettings(true);
setTransitionSaveSuccess(false);
try {
// Check if all values are empty (should delete to use global defaults)
const hasValues =
localTransitionType ||
localDurationMs !== '' ||
localEasing ||
localOverlayColor;
if (!hasValues) {
// Delete the settings record to revert to global defaults
await dispatch(
deleteByProjectAndEnv({
projectId: selectedProjectId,
environment: 'dev',
}),
).unwrap();
} else {
// Build the settings object with snake_case keys for the backend
const settingsToSave = {
transition_type: localTransitionType || 'fade',
duration_ms:
localDurationMs !== '' ? (localDurationMs as number) : 700,
easing: localEasing || 'ease-in-out',
overlay_color: localOverlayColor || '#000000',
};
await dispatch(
upsertByProjectAndEnv({
projectId: selectedProjectId,
environment: 'dev',
data: settingsToSave,
}),
).unwrap();
}
setTransitionSaveSuccess(true);
setTimeout(() => setTransitionSaveSuccess(false), 2000);
} catch (error) {
logger.error('Failed to save project transition settings:', error);
toast.error('Failed to save transition settings');
} finally {
setIsSavingTransitionSettings(false);
}
};
const handleDelete = async ( const handleDelete = async (
event: React.MouseEvent, event: React.MouseEvent,
id: string, id: string,
@ -486,37 +736,208 @@ const TourFlowManager = () => {
/> />
</CardBox> </CardBox>
{/* Project Transition Settings */}
{selectedProjectId && (
<CardBox className='mb-6'>
<button
type='button'
className='flex w-full items-center justify-between text-left'
onClick={() =>
setIsTransitionSettingsExpanded(!isTransitionSettingsExpanded)
}
>
<h3 className='text-sm font-semibold text-gray-700 dark:text-gray-300'>
Project Transition Settings
</h3>
<Icon
path={
isTransitionSettingsExpanded ? mdiChevronUp : mdiChevronDown
}
size={0.8}
className='text-gray-500'
/>
</button>
{isTransitionSettingsExpanded && (
<div className='mt-4'>
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
Override global transition defaults for this project (dev
environment). Changes are copied to Stage when you &quot;Save
to Stage&quot; and to Production when you &quot;Publish&quot;.
Leave empty to use global defaults.
{globalDefaults && (
<span className='ml-1'>
(Global: {globalDefaults.transition_type},{' '}
{globalDefaults.duration_ms}ms, {globalDefaults.easing},{' '}
{globalDefaults.overlay_color ?? '#000000'})
</span>
)}
</p>
<div className='grid grid-cols-1 gap-4 md:grid-cols-4'>
<div>
<label className='mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400'>
Transition Type
</label>
<select
className='w-full rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
value={localTransitionType}
onChange={(e) =>
setLocalTransitionType(
e.target.value as TransitionType | '',
)
}
>
{TRANSITION_TYPES.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400'>
Duration (ms)
</label>
<input
type='number'
min='0'
placeholder='Use global default'
className='w-full rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
value={localDurationMs}
onChange={(e) => {
const val = e.target.value;
setLocalDurationMs(
val === '' ? '' : Math.max(0, parseInt(val, 10) || 0),
);
}}
/>
</div>
<div>
<label className='mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400'>
Easing
</label>
<select
className='w-full rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
value={localEasing}
onChange={(e) =>
setLocalEasing(e.target.value as EasingFunction | '')
}
>
{EASING_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400'>
Overlay Color
</label>
<div className='flex gap-2'>
<input
type='color'
className='h-9 w-12 cursor-pointer rounded border border-gray-300 p-0.5 dark:border-dark-600'
value={localOverlayColor || '#000000'}
onChange={(e) => setLocalOverlayColor(e.target.value)}
/>
<input
type='text'
placeholder='Use global'
className='flex-1 rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
value={localOverlayColor}
onChange={(e) => setLocalOverlayColor(e.target.value)}
/>
</div>
</div>
</div>
<div className='mt-4 flex items-center gap-3'>
<BaseButton
label={
isSavingTransitionSettings ? 'Saving...' : 'Save Settings'
}
color='info'
small
onClick={handleSaveTransitionSettings}
disabled={isSavingTransitionSettings}
/>
{transitionSaveSuccess && (
<span className='text-xs text-green-600'>
Saved successfully!
</span>
)}
</div>
</div>
)}
</CardBox>
)}
<CardBoxModal <CardBoxModal
title='Create page' title='Create page'
buttonColor='info' buttonColor='info'
buttonLabel={isCreatingPage ? 'Creating...' : 'Create'} buttonLabel={isCreatingPage ? 'Creating...' : 'Create'}
isConfirmDisabled={Boolean(slugValidationError) || isCreatingPage} isConfirmDisabled={Boolean(nameValidationError) || isCreatingPage}
isActive={isCreatePageModalActive} isActive={isCreatePageModalActive}
onConfirm={handleCreatePage} onConfirm={handleCreatePage}
onCancel={isCreatingPage ? undefined : closeCreatePageModal} onCancel={isCreatingPage ? undefined : closeCreatePageModal}
> >
<div> <div>
<label <label
htmlFor='new-page-slug' htmlFor='new-page-name'
className='block text-sm font-semibold mb-1' className='block text-sm font-semibold mb-1'
> >
Page slug Page name
</label> </label>
<input <input
id='new-page-slug' id='new-page-name'
type='text' type='text'
value={newPageSlug} value={newPageName}
onChange={(event) => handleSlugChange(event.target.value)} onChange={(event) => handleNameChange(event.target.value)}
placeholder='my-page-slug' placeholder='Enter page name'
className='w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800' className='w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800'
autoFocus autoFocus
maxLength={255}
/> />
<p className='text-xs text-gray-500 mt-2'> {nameValidationError && (
Use lowercase letters, numbers, and hyphens. <p className='text-xs text-red-600 mt-1'>
</p> {nameValidationError}
{(newPageSlugError || slugValidationError) && ( </p>
<p className='text-xs text-red-600 mt-2'> )}
{newPageSlugError || slugValidationError} </div>
</CardBoxModal>
{/* Edit Page Name Modal */}
<CardBoxModal
title='Edit page name'
buttonColor='info'
buttonLabel={isSavingPageName ? 'Saving...' : 'Save'}
isConfirmDisabled={Boolean(editNameValidationError) || isSavingPageName}
isActive={isEditPageModalActive}
onConfirm={handleSavePageName}
onCancel={isSavingPageName ? undefined : closeEditPageModal}
>
<div>
<label
htmlFor='edit-page-name'
className='block text-sm font-semibold mb-1'
>
Page name
</label>
<input
id='edit-page-name'
type='text'
value={editPageName}
onChange={(event) => handleEditNameChange(event.target.value)}
placeholder='Enter page name'
className='w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800'
autoFocus
maxLength={255}
/>
{(editPageNameError || editNameValidationError) && (
<p className='text-xs text-red-600 mt-1'>
{editPageNameError || editNameValidationError}
</p> </p>
)} )}
</div> </div>
@ -543,6 +964,9 @@ const TourFlowManager = () => {
const canDelete = const canDelete =
entry.type === 'page' ? canDeletePage : canDeleteTransition; entry.type === 'page' ? canDeletePage : canDeleteTransition;
const isDeleting = deletingId === entry.id; const isDeleting = deletingId === entry.id;
const pageData = entry.type === 'page'
? pages.find((p) => p.id === entry.id)
: null;
return ( return (
<li key={`${entry.type}-${entry.id}`}> <li key={`${entry.type}-${entry.id}`}>
@ -568,7 +992,7 @@ const TourFlowManager = () => {
} }
}} }}
> >
<div className='pr-8'> <div className='pr-20'>
<p className='text-xs uppercase text-gray-500 mb-1'> <p className='text-xs uppercase text-gray-500 mb-1'>
{entry.description} {entry.description}
</p> </p>
@ -578,17 +1002,29 @@ const TourFlowManager = () => {
</p> </p>
</div> </div>
<BaseButton <div className='absolute top-3 right-3 flex gap-1'>
className='!absolute top-3 right-3' {entry.type === 'page' && pageData && (
icon={mdiClose} <BaseButton
color='danger' icon={mdiPencil}
outline color='info'
small outline
onClick={(event) => small
handleDelete(event, entry.id, entry.type) onClick={(event) =>
} openEditPageModal(event, pageData)
disabled={!canDelete || isDeleting} }
/> />
)}
<BaseButton
icon={mdiClose}
color='danger'
outline
small
onClick={(event) =>
handleDelete(event, entry.id, entry.type)
}
disabled={!canDelete || isDeleting}
/>
</div>
</div> </div>
</li> </li>
); );

View File

@ -0,0 +1,53 @@
/**
* TransitionBlackOverlay Component
*
* Overlay for fade-through-color page transitions.
* Must be rendered at a z-index ABOVE the elements layer to properly cover them.
*
* The fade-from-color effect:
* 1. Page switches instantly (hidden by opaque overlay)
* 2. Overlay fades out (1 0) revealing new page
*
* Used by both constructor.tsx and RuntimePresentation.tsx.
*/
import React from 'react';
import type { TransitionType } from '../types/transition';
interface TransitionBlackOverlayProps {
/** Whether fade animation is in progress */
isFadingIn: boolean;
/** Transition type - only renders overlay for 'fade' type */
transitionType?: TransitionType;
/** Inline styles for transition duration/easing (from usePageNavigationState) */
transitionStyle?: React.CSSProperties;
/** Overlay color (default: #000000) */
overlayColor?: string;
/** Additional CSS classes */
className?: string;
}
const TransitionBlackOverlay: React.FC<TransitionBlackOverlayProps> = ({
isFadingIn,
transitionType = 'fade',
transitionStyle,
overlayColor = '#000000',
className = '',
}) => {
// Only render overlay for fade transitions
// 'none' means instant switch (no animation)
// 'video' is handled by TransitionPreviewOverlay
if (!isFadingIn || transitionType !== 'fade') return null;
return (
<div
className={`pointer-events-none absolute inset-0 z-[100] animate-fade-from-black ${className}`}
style={{
...transitionStyle,
backgroundColor: overlayColor,
}}
/>
);
};
export default TransitionBlackOverlay;

View File

@ -9,8 +9,14 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import Icon from '@mdi/react'; import Icon from '@mdi/react';
import { mdiChevronLeft, mdiChevronRight, mdiArrowLeft } from '@mdi/js'; import { mdiChevronLeft, mdiChevronRight, mdiArrowLeft } from '@mdi/js';
import type { GalleryCard } from '../../types/constructor'; import type { GalleryCard, CanvasElement } from '../../types/constructor';
import type { ResolvedTransitionSettings } from '../../types/transition';
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl'; import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
import {
resolveSlideTransition,
extractGallerySlideOverride,
} from '../../lib/resolveSlideTransition';
import { useSlideTransition } from '../../hooks/useSlideTransition';
interface GalleryCarouselOverlayProps { interface GalleryCarouselOverlayProps {
cards: GalleryCard[]; cards: GalleryCard[];
@ -46,6 +52,10 @@ interface GalleryCarouselOverlayProps {
) => void; ) => void;
// Letterbox styles for constraining overlay to canvas bounds // Letterbox styles for constraining overlay to canvas bounds
letterboxStyles?: React.CSSProperties; letterboxStyles?: React.CSSProperties;
// Page transition settings (for slide transition cascade)
pageTransitionSettings?: ResolvedTransitionSettings;
// Gallery element (for extracting slide transition override)
galleryElement?: CanvasElement;
} }
const clamp = (value: number, min: number, max: number) => const clamp = (value: number, min: number, max: number) =>
@ -75,9 +85,33 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
isEditMode = false, isEditMode = false,
onButtonPositionChange, onButtonPositionChange,
letterboxStyles, letterboxStyles,
pageTransitionSettings,
galleryElement,
}) => { }) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl; const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const [currentIndex, setCurrentIndex] = useState(initialIndex);
// Resolve slide transition with cascade
const slideTransition = resolveSlideTransition(
pageTransitionSettings,
extractGallerySlideOverride(galleryElement),
);
// Use hook for animation state
const {
displayIndex,
overlayOpacity,
overlayColor,
goToIndex,
setInitialIndex,
slideTransitionStyle,
overlayTransitionStyle,
slideOpacity,
} = useSlideTransition(slideTransition);
// Set initial index on mount
useEffect(() => {
setInitialIndex(initialIndex);
}, [initialIndex, setInitialIndex]);
const [draggingButton, setDraggingButton] = useState< const [draggingButton, setDraggingButton] = useState<
'prev' | 'next' | 'back' | null 'prev' | 'next' | 'back' | null
>(null); >(null);
@ -111,13 +145,15 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
// Navigation handlers // Navigation handlers
const goToPrev = useCallback(() => { const goToPrev = useCallback(() => {
if (cards.length === 0) return; if (cards.length === 0) return;
setCurrentIndex((prev) => (prev - 1 + cards.length) % cards.length); const newIndex = (displayIndex - 1 + cards.length) % cards.length;
}, [cards.length]); goToIndex(newIndex);
}, [cards.length, displayIndex, goToIndex]);
const goToNext = useCallback(() => { const goToNext = useCallback(() => {
if (cards.length === 0) return; if (cards.length === 0) return;
setCurrentIndex((prev) => (prev + 1) % cards.length); const newIndex = (displayIndex + 1) % cards.length;
}, [cards.length]); goToIndex(newIndex);
}, [cards.length, displayIndex, goToIndex]);
// Keyboard navigation // Keyboard navigation
useEffect(() => { useEffect(() => {
@ -348,7 +384,7 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
); );
}; };
const currentCard = cards[currentIndex]; const currentCard = cards[displayIndex];
const imageUrl = currentCard?.imageUrl ? resolve(currentCard.imageUrl) : ''; const imageUrl = currentCard?.imageUrl ? resolve(currentCard.imageUrl) : '';
return ( return (
@ -377,9 +413,21 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
src={imageUrl} src={imageUrl}
alt={currentCard?.title || ''} alt={currentCard?.title || ''}
className='absolute inset-0 h-full w-full object-contain' className='absolute inset-0 h-full w-full object-contain'
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
draggable={false} draggable={false}
/> />
)} )}
{/* Transition overlay (fades in during fadingOut, fades out during fadingIn) */}
{slideTransition.type === 'fade' && (
<div
className='absolute inset-0 pointer-events-none'
style={{
...overlayTransitionStyle,
backgroundColor: overlayColor,
opacity: overlayOpacity,
}}
/>
)}
{/* Prev button */} {/* Prev button */}
{renderNavButton( {renderNavButton(

View File

@ -10,6 +10,8 @@
import React from 'react'; import React from 'react';
import type { CanvasElement } from '../../types/constructor'; import type { CanvasElement } from '../../types/constructor';
import type { ResolvedTransitionSettings } from '../../types/transition';
import type { PreloadCacheProvider } from '../../hooks/video';
import { useElementWrapperStyle } from './shared/useElementWrapperStyle'; import { useElementWrapperStyle } from './shared/useElementWrapperStyle';
import { import {
isNavigationElementType, isNavigationElementType,
@ -42,7 +44,6 @@ export interface UiElementRendererProps {
// Constructor-specific props (optional) // Constructor-specific props (optional)
isSelected?: boolean; isSelected?: boolean;
isEditMode?: boolean; isEditMode?: boolean;
isDisabled?: boolean;
// Gallery carousel callback // Gallery carousel callback
onGalleryCardClick?: (cardIndex: number) => void; onGalleryCardClick?: (cardIndex: number) => void;
// Carousel-specific callback for button position changes (constructor only) // Carousel-specific callback for button position changes (constructor only)
@ -53,6 +54,10 @@ export interface UiElementRendererProps {
) => void; ) => void;
// Letterbox styles for constraining fullscreen elements to canvas bounds // Letterbox styles for constraining fullscreen elements to canvas bounds
letterboxStyles?: React.CSSProperties; letterboxStyles?: React.CSSProperties;
// Page transition settings (for slide transition cascade in carousel/gallery)
pageTransitionSettings?: ResolvedTransitionSettings;
// Preload cache provider for video elements
preloadCache?: PreloadCacheProvider;
} }
/** /**
@ -66,16 +71,16 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
resolveUrl, resolveUrl,
isSelected = false, isSelected = false,
isEditMode = false, isEditMode = false,
isDisabled = false,
onGalleryCardClick, onGalleryCardClick,
onCarouselButtonPositionChange, onCarouselButtonPositionChange,
letterboxStyles, letterboxStyles,
pageTransitionSettings,
preloadCache,
}) => { }) => {
const { className, style } = useElementWrapperStyle({ const { className, style } = useElementWrapperStyle({
element, element,
isSelected, isSelected,
isEditMode, isEditMode,
isDisabled,
}); });
// Common props for all element types // Common props for all element types
@ -101,11 +106,12 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
isEditMode={isEditMode} isEditMode={isEditMode}
onButtonPositionChange={onCarouselButtonPositionChange} onButtonPositionChange={onCarouselButtonPositionChange}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
/> />
); );
} }
if (isVideoPlayerElementType(element.type)) { if (isVideoPlayerElementType(element.type)) {
return <VideoPlayerElement {...commonProps} />; return <VideoPlayerElement {...commonProps} preloadCache={preloadCache} />;
} }
if (isAudioPlayerElementType(element.type)) { if (isAudioPlayerElementType(element.type)) {
return <AudioPlayerElement {...commonProps} />; return <AudioPlayerElement {...commonProps} />;

View File

@ -15,20 +15,26 @@
*/ */
import React, { import React, {
useState,
useMemo, useMemo,
useCallback, useCallback,
useEffect, useEffect,
useRef, useRef,
useState,
} from 'react'; } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import Icon from '@mdi/react'; import Icon from '@mdi/react';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js'; import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import type { CanvasElement, CarouselSlide } from '../../../types/constructor'; import type { CanvasElement, CarouselSlide } from '../../../types/constructor';
import type { ResolvedTransitionSettings } from '../../../types/transition';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl'; import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
import { getFontByKey, getFontStyle } from '../../../lib/fonts'; import { getFontByKey, getFontStyle } from '../../../lib/fonts';
import { toCU } from '../../../lib/canvasScale'; import { toCU } from '../../../lib/canvasScale';
import {
resolveSlideTransition,
extractCarouselSlideOverride,
} from '../../../lib/resolveSlideTransition';
import { useSlideTransition } from '../../../hooks/useSlideTransition';
interface CarouselElementProps { interface CarouselElementProps {
element: CanvasElement; element: CanvasElement;
@ -44,6 +50,8 @@ interface CarouselElementProps {
) => void; ) => void;
// Letterbox styles for constraining full-width carousel to canvas bounds // Letterbox styles for constraining full-width carousel to canvas bounds
letterboxStyles?: CSSProperties; letterboxStyles?: CSSProperties;
// Page transition settings (for slide transition cascade)
pageTransitionSettings?: ResolvedTransitionSettings;
} }
const clamp = (value: number, min: number, max: number) => const clamp = (value: number, min: number, max: number) =>
@ -57,13 +65,31 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
isEditMode = false, isEditMode = false,
onButtonPositionChange, onButtonPositionChange,
letterboxStyles, letterboxStyles,
pageTransitionSettings,
}) => { }) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl; const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const slides: CarouselSlide[] = element.carouselSlides || []; const slides: CarouselSlide[] = element.carouselSlides || [];
const [currentIndex, setCurrentIndex] = useState(0);
const currentSlide = slides[currentIndex] || slides[0];
const isFullWidth = element.carouselFullWidth || false; const isFullWidth = element.carouselFullWidth || false;
// Resolve slide transition with cascade
const slideTransition = resolveSlideTransition(
pageTransitionSettings,
extractCarouselSlideOverride(element),
);
// Use hook for animation state
const {
displayIndex,
overlayOpacity,
overlayColor,
goToIndex,
slideTransitionStyle,
overlayTransitionStyle,
slideOpacity,
} = useSlideTransition(slideTransition);
const currentSlide = slides[displayIndex] || slides[0];
// Drag state (constructor edit mode only) // Drag state (constructor edit mode only)
const [draggingButton, setDraggingButton] = useState<'prev' | 'next' | null>( const [draggingButton, setDraggingButton] = useState<'prev' | 'next' | null>(
null, null,
@ -100,13 +126,15 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
// Navigation handlers (no event parameter for keyboard/swipe use) // Navigation handlers (no event parameter for keyboard/swipe use)
const goToPrev = useCallback(() => { const goToPrev = useCallback(() => {
if (slides.length === 0) return; if (slides.length === 0) return;
setCurrentIndex((prev) => (prev - 1 + slides.length) % slides.length); const newIndex = (displayIndex - 1 + slides.length) % slides.length;
}, [slides.length]); goToIndex(newIndex);
}, [slides.length, displayIndex, goToIndex]);
const goToNext = useCallback(() => { const goToNext = useCallback(() => {
if (slides.length === 0) return; if (slides.length === 0) return;
setCurrentIndex((prev) => (prev + 1) % slides.length); const newIndex = (displayIndex + 1) % slides.length;
}, [slides.length]); goToIndex(newIndex);
}, [slides.length, displayIndex, goToIndex]);
// Click handlers for buttons (with event propagation control) // Click handlers for buttons (with event propagation control)
const handlePrevClick = useCallback( const handlePrevClick = useCallback(
@ -365,9 +393,21 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
src={resolve(currentSlide.imageUrl)} src={resolve(currentSlide.imageUrl)}
alt={currentSlide.caption || 'Carousel slide'} alt={currentSlide.caption || 'Carousel slide'}
className='absolute inset-0 w-full h-full object-contain' className='absolute inset-0 w-full h-full object-contain'
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
draggable={false} draggable={false}
/> />
)} )}
{/* Transition overlay (fades in during fadingOut, fades out during fadingIn) */}
{slideTransition.type === 'fade' && (
<div
className='absolute inset-0 pointer-events-none'
style={{
...overlayTransitionStyle,
backgroundColor: overlayColor,
opacity: overlayOpacity,
}}
/>
)}
</div> </div>
</div> </div>
); );
@ -459,7 +499,7 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
// Normal mode: inline carousel within element dimensions // Normal mode: inline carousel within element dimensions
return ( return (
<div className={className} style={style}> <div className={className} style={style}>
<div className='relative w-full h-full min-w-[120px] min-h-[80px]'> <div className='relative w-full h-full min-w-[120px] min-h-[80px] overflow-hidden'>
{/* Current slide image */} {/* Current slide image */}
{currentSlide?.imageUrl && ( {currentSlide?.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@ -467,9 +507,21 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
src={resolve(currentSlide.imageUrl)} src={resolve(currentSlide.imageUrl)}
alt={currentSlide.caption || 'Carousel slide'} alt={currentSlide.caption || 'Carousel slide'}
className='w-full h-full object-cover rounded' className='w-full h-full object-cover rounded'
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
draggable={false} draggable={false}
/> />
)} )}
{/* Transition overlay */}
{slideTransition.type === 'fade' && (
<div
className='absolute inset-0 pointer-events-none rounded'
style={{
...overlayTransitionStyle,
backgroundColor: overlayColor,
opacity: overlayOpacity,
}}
/>
)}
{/* Navigation buttons */} {/* Navigation buttons */}
{showNavigation && ( {showNavigation && (

View File

@ -2,28 +2,43 @@
* VideoPlayerElement Component * VideoPlayerElement Component
* *
* Video player element with controls. * Video player element with controls.
* Renders with unified wrapper styling + content. * Uses unified video hook for consistent behavior:
* - Multi-tier URL resolution (blob cached presigned proxy)
* - Safari decode error recovery
* - Buffering state indicator
*/ */
import React from 'react'; import React from 'react';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import type { CanvasElement } from '../../../types/constructor'; import type { CanvasElement } from '../../../types/constructor';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl'; import type { PreloadCacheProvider } from '../../../hooks/video';
import { useVideoPlayer } from '../../../hooks/video';
interface VideoPlayerElementProps { interface VideoPlayerElementProps {
element: CanvasElement; element: CanvasElement;
resolveUrl?: (url: string | undefined) => string; preloadCache?: PreloadCacheProvider;
className: string; className: string;
style: CSSProperties; style: CSSProperties;
} }
const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({ const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
element, element,
resolveUrl, preloadCache,
className, className,
style, style,
}) => { }) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl; const {
videoRef,
resolvedUrl,
isBuffering,
} = useVideoPlayer({
sourceUrl: element.mediaUrl,
preloadCache,
autoplay: Boolean(element.mediaAutoplay),
loop: Boolean(element.mediaLoop),
muted: Boolean(element.mediaMuted),
trackBuffering: true,
});
if (!element.mediaUrl) { if (!element.mediaUrl) {
return ( return (
@ -35,9 +50,16 @@ const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
return ( return (
<div className={className} style={style}> <div className={className} style={style}>
{/* Loading spinner during buffering */}
{isBuffering && (
<div className='absolute inset-0 flex items-center justify-center bg-black/20 rounded z-10'>
<div className='w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin' />
</div>
)}
<video <video
className='w-full h-full object-cover rounded' ref={videoRef}
src={resolve(element.mediaUrl)} className={`w-full h-full object-cover rounded ${isBuffering ? 'opacity-70' : ''}`}
src={resolvedUrl || ''}
controls controls
autoPlay={Boolean(element.mediaAutoplay)} autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)} loop={Boolean(element.mediaLoop)}

View File

@ -27,8 +27,6 @@ interface UseElementWrapperStyleOptions {
isSelected?: boolean; isSelected?: boolean;
/** Constructor-specific: show edit mode styling */ /** Constructor-specific: show edit mode styling */
isEditMode?: boolean; isEditMode?: boolean;
/** Constructor-specific: show disabled styling */
isDisabled?: boolean;
} }
interface ElementWrapperStyle { interface ElementWrapperStyle {
@ -47,7 +45,6 @@ export function useElementWrapperStyle({
element, element,
isSelected = false, isSelected = false,
isEditMode = false, isEditMode = false,
isDisabled = false,
}: UseElementWrapperStyleOptions): ElementWrapperStyle { }: UseElementWrapperStyleOptions): ElementWrapperStyle {
return useMemo(() => { return useMemo(() => {
// Determine element characteristics // Determine element characteristics
@ -100,11 +97,7 @@ export function useElementWrapperStyle({
// Flex centering for navigation elements (both icons and text) // Flex centering for navigation elements (both icons and text)
isNavigationElement ? 'flex items-center justify-center' : '', isNavigationElement ? 'flex items-center justify-center' : '',
// Constructor-specific states (only applied when in constructor) // Constructor-specific states (only applied when in constructor)
isEditMode isEditMode ? 'cursor-move' : 'cursor-pointer',
? 'cursor-move'
: isDisabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer',
] ]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
@ -112,9 +105,16 @@ export function useElementWrapperStyle({
// Build inline style from element properties // Build inline style from element properties
const inlineStyle = buildElementStyle(element); const inlineStyle = buildElementStyle(element);
// If element has configured padding, use it exclusively (skip default paddingStyle)
// This avoids React warning about conflicting shorthand/non-shorthand properties
const finalStyle =
'padding' in inlineStyle
? inlineStyle
: { ...paddingStyle, ...inlineStyle };
return { return {
className: classNames, className: classNames,
style: { ...paddingStyle, ...inlineStyle }, style: finalStyle,
}; };
}, [element, isSelected, isEditMode, isDisabled]); }, [element, isSelected, isEditMode]);
} }

View File

@ -26,6 +26,7 @@ export const OFFLINE_CONFIG = {
projectDownloadComplete: 'project-download-complete', projectDownloadComplete: 'project-download-complete',
queueUpdate: 'queue-update', queueUpdate: 'queue-update',
blobUrlReady: 'blob-url-ready', blobUrlReady: 'blob-url-ready',
streamingReady: 'streaming-ready',
}, },
// Service worker settings // Service worker settings

View File

@ -1,27 +1,22 @@
/** /**
* Preload Configuration * Preload Configuration
*
* Centralized configuration for asset preloading, priority weights, and queue settings.
*/ */
export const PRELOAD_CONFIG = { export const PRELOAD_CONFIG = {
// Queue settings
maxConcurrentDownloads: 3, maxConcurrentDownloads: 3,
maxRetries: 3, maxRetries: 3,
retryDelayMs: 1000, retryDelayMs: 1000,
// Size thresholds largeFileThreshold: 5 * 1024 * 1024,
largeFileThreshold: 5 * 1024 * 1024, // 5MB -> use IndexedDB videoChunkSize: 5 * 1024 * 1024,
videoChunkSize: 5 * 1024 * 1024, // 5MB chunks
initialVideoBufferSeconds: 5, initialVideoBufferSeconds: 5,
// Priority weights (higher = load first)
priority: { priority: {
currentPage: 1000, currentPage: 1000,
neighborBase: 500, neighborBase: 500,
assetType: { assetType: {
transition: 150, // Transitions preloaded for faster start transition: 150,
image: 100, // Backgrounds load first image: 100,
audio: 50, audio: 50,
video: 30, video: 30,
} as Record<string, number>, } as Record<string, number>,
@ -37,38 +32,39 @@ export const PRELOAD_CONFIG = {
maxLinkBonus: 50, maxLinkBonus: 50,
}, },
// Storage
storage: { storage: {
warningPercent: 80, warningPercent: 80,
criticalPercent: 95, criticalPercent: 95,
minFreeBuffer: 50 * 1024 * 1024, // 50MB minFreeBuffer: 50 * 1024 * 1024,
}, },
// Auto-cleanup timeouts (from hoboken pattern)
autoRemove: { autoRemove: {
completedMs: 3000, completedMs: 3000,
errorMs: 10000, errorMs: 10000,
}, },
// Neighbor graph traversal
neighborGraph: {
maxDepth: 1, // Only preload immediate neighbors (depth 2 was causing too many requests)
constructorMaxDepth: 1, // Same as maxDepth for constructor preview
},
// Partial preload settings (online mode only)
// Download only first N bytes of videos/audio for faster Phase 1 completion
// Playback uses presigned URL directly (browser handles remaining buffering)
partialPreload: { partialPreload: {
enabled: true, enabled: true,
videoMaxBytes: 5 * 1024 * 1024, // 5MB (~5 seconds of video) videoMaxBytes: 5 * 1024 * 1024,
audioMaxBytes: 512 * 1024, // 512KB (~5 seconds of audio) audioMaxBytes: 512 * 1024,
transitionMaxBytes: 3 * 1024 * 1024, // 3MB (~3 seconds of transition video) transitionMaxBytes: 3 * 1024 * 1024,
},
streaming: {
enabled: true,
minBufferBytes: 3 * 1024 * 1024,
videoBufferTarget: 5,
audioBufferTarget: 3,
mobile: {
minBufferBytes: 2 * 1024 * 1024,
},
},
ui: {
spinnerDelayMs: 500,
}, },
// Asset URL field names in element content_json (camelCase)
assetFields: { assetFields: {
// All asset URL fields for preloading extraction
all: [ all: [
'iconUrl', 'iconUrl',
'imageUrl', 'imageUrl',
@ -89,7 +85,6 @@ export const PRELOAD_CONFIG = {
'poster', 'poster',
'thumbnail', 'thumbnail',
] as const, ] as const,
// Image-only fields for decode before page switch
images: [ images: [
'iconUrl', 'iconUrl',
'imageUrl', 'imageUrl',
@ -102,9 +97,7 @@ export const PRELOAD_CONFIG = {
'galleryCarouselBackIconUrl', 'galleryCarouselBackIconUrl',
'src', 'src',
] as const, ] as const,
// Nested array fields containing assets
nested: ['galleryCards', 'carouselSlides', 'galleryInfoSpans'] as const, nested: ['galleryCards', 'carouselSlides', 'galleryInfoSpans'] as const,
// Fields within nested items that contain URLs
nestedUrlFields: ['imageUrl', 'videoUrl', 'iconUrl'] as const, nestedUrlFields: ['imageUrl', 'videoUrl', 'iconUrl'] as const,
}, },
} as const; } as const;

View File

@ -0,0 +1,35 @@
/**
* Transition Playback Configuration
*/
export const TRANSITION_CONFIG = {
finishBeforeEndMs: {
safari: 100,
firefox: 100,
default: 100,
},
timeUpdateSafetyBuffer: {
safari: 0.15,
default: 0.1,
},
rvfcThreshold: 0.1,
timeouts: {
loadTimeoutMs: 10000,
playTimeoutMs: 5000,
},
retry: {
maxDecodeRetries: 1,
},
progressTimeout: {
noProgressMs: 15000,
checkIntervalMs: 5000,
mobileMultiplier: 2,
},
} as const;
export type TransitionConfig = typeof TRANSITION_CONFIG;

View File

@ -0,0 +1,163 @@
/**
* PageNavigationContext
*
* Context provider for sharing page navigation state with child components.
* Wraps the usePageNavigationState hook to provide state machine access
* throughout the component tree without prop drilling.
*
* Usage:
* ```tsx
* // In parent (constructor.tsx or RuntimePresentation.tsx)
* <PageNavigationProvider preloadCache={preloadOrchestrator} transitionSettings={transitionSettings}>
* <CanvasBackground />
* <Elements />
* </PageNavigationProvider>
*
* // In child components
* const { phase, onBackgroundReady, isSwitching } = usePageNavigationContext();
* ```
*/
import React, { createContext, useContext, ReactNode, useMemo } from 'react';
import {
usePageNavigationState,
UsePageNavigationStateOptions,
UsePageNavigationStateResult,
} from '../hooks/usePageNavigationState';
// ============================================================================
// Context
// ============================================================================
const PageNavigationContext =
createContext<UsePageNavigationStateResult | null>(null);
// ============================================================================
// Provider
// ============================================================================
export interface PageNavigationProviderProps
extends UsePageNavigationStateOptions {
children: ReactNode;
}
/**
* Provider component that wraps usePageNavigationState and exposes it via context.
*
* @example
* ```tsx
* <PageNavigationProvider
* preloadCache={preloadOrchestrator}
* transitionSettings={transitionSettings}
* >
* <CanvasBackground ... />
* <PageElements ... />
* </PageNavigationProvider>
* ```
*/
export function PageNavigationProvider({
children,
...options
}: PageNavigationProviderProps) {
const navState = usePageNavigationState(options);
return (
<PageNavigationContext.Provider value={navState}>
{children}
</PageNavigationContext.Provider>
);
}
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook to access page navigation state from context.
* Must be used within a PageNavigationProvider.
*
* @throws Error if used outside of PageNavigationProvider
*
* @example
* ```tsx
* function CanvasBackground() {
* const { onBackgroundReady, isSwitching, isNewBgReady } = usePageNavigationContext();
*
* return (
* <img
* onLoad={onBackgroundReady}
* style={{ opacity: isNewBgReady ? 1 : 0 }}
* />
* );
* }
* ```
*/
export function usePageNavigationContext(): UsePageNavigationStateResult {
const ctx = useContext(PageNavigationContext);
if (!ctx) {
throw new Error(
'usePageNavigationContext must be used within a PageNavigationProvider',
);
}
return ctx;
}
/**
* Hook to optionally access page navigation state from context.
* Returns null if used outside of PageNavigationProvider.
* Useful for components that may be used both inside and outside the provider.
*
* @example
* ```tsx
* function FlexibleComponent() {
* const navState = usePageNavigationContextOptional();
*
* if (navState) {
* // Inside provider - use navigation state
* return <div>Phase: {navState.phase}</div>;
* }
*
* // Outside provider - render without navigation state
* return <div>Standalone mode</div>;
* }
* ```
*/
export function usePageNavigationContextOptional(): UsePageNavigationStateResult | null {
return useContext(PageNavigationContext);
}
// ============================================================================
// Selector Hook
// ============================================================================
/**
* Hook to select specific parts of the navigation state.
* Helps with performance by allowing components to subscribe to only what they need.
*
* @example
* ```tsx
* // Only re-render when phase changes
* const phase = usePageNavigationSelector(state => state.phase);
*
* // Get multiple values
* const { isSwitching, isNewBgReady } = usePageNavigationSelector(state => ({
* isSwitching: state.isSwitching,
* isNewBgReady: state.isNewBgReady,
* }));
* ```
*/
export function usePageNavigationSelector<T>(
selector: (state: UsePageNavigationStateResult) => T,
): T {
const ctx = usePageNavigationContext();
// Note: This doesn't prevent re-renders on its own since the context value changes.
// For true selector optimization, consider using useSyncExternalStore or a state management library.
// This is primarily for code organization/readability.
return useMemo(() => selector(ctx), [ctx, selector]);
}
// ============================================================================
// Exports
// ============================================================================
export type { UsePageNavigationStateResult } from '../hooks/usePageNavigationState';

View File

@ -12,6 +12,15 @@
@import '_theme.css'; @import '_theme.css';
@import '_rich-text.css'; @import '_rich-text.css';
/*
Custom Font Declarations
*/
@font-face {
font-family: 'Maple';
src: url('/fonts/MapleMedium.otf') format('opentype');
}
/* Page transition timing - single source of truth */ /* Page transition timing - single source of truth */
:root { :root {
--crossfade-duration: 700ms; --crossfade-duration: 700ms;
@ -107,8 +116,20 @@
} }
} }
/* Fade from black animation - black overlay fades out 1 → 0 */
/* Page has already switched, black covers it then reveals new content */
@keyframes fade-from-black {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
/* Crossfade animation classes - GPU accelerated for all browsers */ /* Crossfade animation classes - GPU accelerated for all browsers */
/* Duration controlled by --crossfade-duration CSS variable (single source of truth) */ /* Duration controlled by --transition-duration (from JS) or --crossfade-duration (CSS variable) */
/* This allows the hierarchical transition settings to override the default CSS duration */
.animate-crossfade-in { .animate-crossfade-in {
/* Explicit initial state prevents flash during animation setup */ /* Explicit initial state prevents flash during animation setup */
opacity: 0; opacity: 0;
@ -117,15 +138,15 @@
/* Full animation property for maximum browser compatibility */ /* Full animation property for maximum browser compatibility */
-webkit-animation-name: page-crossfade-in; -webkit-animation-name: page-crossfade-in;
animation-name: page-crossfade-in; animation-name: page-crossfade-in;
-webkit-animation-duration: var(--crossfade-duration, 700ms); -webkit-animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
animation-duration: var(--crossfade-duration, 700ms); animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
-webkit-animation-timing-function: var( -webkit-animation-timing-function: var(
--crossfade-easing, --transition-easing,
cubic-bezier(0.4, 0, 0.2, 1) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
); );
animation-timing-function: var( animation-timing-function: var(
--crossfade-easing, --transition-easing,
cubic-bezier(0.4, 0, 0.2, 1) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
); );
-webkit-animation-fill-mode: forwards; -webkit-animation-fill-mode: forwards;
animation-fill-mode: forwards; animation-fill-mode: forwards;
@ -146,15 +167,15 @@
/* Full animation property for maximum browser compatibility */ /* Full animation property for maximum browser compatibility */
-webkit-animation-name: page-crossfade-out; -webkit-animation-name: page-crossfade-out;
animation-name: page-crossfade-out; animation-name: page-crossfade-out;
-webkit-animation-duration: var(--crossfade-duration, 700ms); -webkit-animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
animation-duration: var(--crossfade-duration, 700ms); animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
-webkit-animation-timing-function: var( -webkit-animation-timing-function: var(
--crossfade-easing, --transition-easing,
cubic-bezier(0.4, 0, 0.2, 1) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
); );
animation-timing-function: var( animation-timing-function: var(
--crossfade-easing, --transition-easing,
cubic-bezier(0.4, 0, 0.2, 1) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
); );
-webkit-animation-fill-mode: forwards; -webkit-animation-fill-mode: forwards;
animation-fill-mode: forwards; animation-fill-mode: forwards;
@ -190,6 +211,32 @@
} }
} }
/* Fade from black animation class - for smooth page transitions via black overlay */
/* Black starts opaque (hiding page switch), then fades out to reveal new page */
.animate-fade-from-black {
opacity: 1;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
-webkit-animation-name: fade-from-black;
animation-name: fade-from-black;
-webkit-animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
-webkit-animation-timing-function: var(
--transition-easing,
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
);
animation-timing-function: var(
--transition-easing,
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
);
-webkit-animation-fill-mode: forwards;
animation-fill-mode: forwards;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
will-change: opacity;
contain: layout style paint;
}
/* Transition-based crossfade (Safari-optimized alternative to animations) */ /* Transition-based crossfade (Safari-optimized alternative to animations) */
/* Use this for better Safari stability - transitions don't have the "snap" issue /* Use this for better Safari stability - transitions don't have the "snap" issue
when state changes because they interpolate between current and target values */ when state changes because they interpolate between current and target values */
@ -213,6 +260,19 @@
} }
} }
/* =============================================================
Page Transition Animations (Slide, Zoom)
These animations support the hierarchical transition settings:
Global Project Element cascade with configurable:
- Duration: --transition-duration CSS variable
- Easing: --transition-easing CSS variable
- Overlay Color: --overlay-color CSS variable (used by TransitionBlackOverlay)
Animation classes use these variables when present, falling back
to --crossfade-duration/--crossfade-easing for compatibility.
============================================================= */
/* Element appear animation keyframes - Safari optimized */ /* Element appear animation keyframes - Safari optimized */
@-webkit-keyframes element-fade-in { @-webkit-keyframes element-fade-in {
from { from {

View File

@ -20,12 +20,14 @@ export type {
UsePageNavigationOptions, UsePageNavigationOptions,
UsePageNavigationResult, UsePageNavigationResult,
} from './usePageNavigation'; } from './usePageNavigation';
export { useBackgroundTransition } from './useBackgroundTransition'; export { usePageNavigationState } from './usePageNavigationState';
export type { export type {
FadeOutConfig, NavigationPhase,
UseBackgroundTransitionOptions, NavigablePage as NavStatePage,
UseBackgroundTransitionResult, PreloadCacheProvider,
} from './useBackgroundTransition'; UsePageNavigationStateOptions,
UsePageNavigationStateResult,
} from './usePageNavigationState';
export { useBackgroundVideoPlayback } from './useBackgroundVideoPlayback'; export { useBackgroundVideoPlayback } from './useBackgroundVideoPlayback';
export type { export type {
UseBackgroundVideoPlaybackOptions, UseBackgroundVideoPlaybackOptions,
@ -54,6 +56,7 @@ export type {
UseTransitionCreationOptions, UseTransitionCreationOptions,
UseTransitionCreationResult, UseTransitionCreationResult,
} from './useTransitionCreation'; } from './useTransitionCreation';
export { useSlideTransition } from './useSlideTransition';
// Constructor hooks - import directly for better tree-shaking: // Constructor hooks - import directly for better tree-shaking:
// import { useOutsideClick } from '../hooks/useOutsideClick'; // import { useOutsideClick } from '../hooks/useOutsideClick';
@ -65,3 +68,7 @@ export type {
// import { useTransitionPreview } from '../hooks/useTransitionPreview'; // import { useTransitionPreview } from '../hooks/useTransitionPreview';
// import { useConstructorElements } from '../hooks/useConstructorElements'; // import { useConstructorElements } from '../hooks/useConstructorElements';
// import { useConstructorPageActions } from '../hooks/useConstructorPageActions'; // import { useConstructorPageActions } from '../hooks/useConstructorPageActions';
// Video primitives - for building custom video playback hooks:
// import { useVideoEventManager, useVideoBufferingState, ... } from './video';
export * from './video';

View File

@ -1,370 +0,0 @@
/**
* useBackgroundTransition Hook
*
* Manages background transition effects when switching between pages.
* Handles crossfade animation for non-video navigation and
* coordinates with the page switch hook to clear previous backgrounds.
*
* This hook consolidates the background transition logic used by both
* RuntimePresentation and constructor.tsx.
*
* NOTE: Video transitions do NOT use any fades - the video itself IS the transition.
* Video last frame = new page background, then overlay is removed instantly.
*
* Two modes:
* 1. fadeIn mode: Crossfade animation for direct (non-video) page navigation
* 2. fadeOut mode: Legacy support - kept for backwards compatibility but not recommended
*
* Cross-browser notes:
* - Chrome: Uses CSS animations with onAnimationEnd event
* - Safari: Uses JS timer fallback (Safari's onAnimationEnd can be unreliable)
* - Firefox: Uses CSS animations with onAnimationEnd event
*/
import {
useEffect,
useLayoutEffect,
useState,
useCallback,
useRef,
} from 'react';
import {
isSafari,
scheduleAfterPaintSafari,
getCrossfadeDuration,
} from '../lib/browserUtils';
/**
* Fade-out configuration (optional - for RuntimePresentation)
*/
export interface FadeOutConfig {
/** Whether a transition video has finished playing and is waiting for bg ready */
pendingTransitionComplete: boolean;
/** Whether the new background image is ready to display */
isBackgroundReady: boolean;
/** Ref to the transition video element for cleanup */
transitionVideoRef: React.RefObject<HTMLVideoElement | null>;
/** Callback to clear transition state after overlay is removed */
onTransitionCleanup: () => void;
}
/**
* Fade-in configuration (optional - for page content fade-in)
*/
export interface FadeInConfig {
/** Whether a transition video is currently active (disables fade-in) */
hasActiveTransition: boolean;
}
export interface UseBackgroundTransitionOptions {
/** Page switch hook instance for clearing previous background */
pageSwitch: {
clearPreviousBackground: () => void;
isSwitching: boolean;
isNewBgReady: boolean;
previousBgImageUrl: string;
previousBgVideoUrl: string;
};
/** Optional fade-out configuration (for RuntimePresentation) */
fadeOut?: FadeOutConfig;
/** Optional fade-in configuration for page content */
fadeIn?: FadeInConfig;
}
export interface UseBackgroundTransitionResult {
/** Whether the overlay is currently fading out */
isOverlayFadingOut: boolean;
/** Reset the fade-out state (call before starting a new transition) */
resetFadeOut: () => void;
/** Whether page content is currently fading (crossfade in progress) */
isFadingIn: boolean;
/** Handler to call when fade-in animation ends (pass to onAnimationEnd) */
onFadeInAnimationEnd: (e?: React.AnimationEvent) => void;
/** Reset fade-in state (for cleanup or cancellation) */
resetFadeIn: () => void;
}
/**
* Hook for managing background transition effects.
*
* @example
* // Full mode with fade-out and fade-in (RuntimePresentation)
* const { isOverlayFadingOut, resetFadeOut, isFadingIn, onFadeInAnimationEnd } = useBackgroundTransition({
* pageSwitch,
* fadeOut: {
* pendingTransitionComplete,
* isBackgroundReady,
* transitionVideoRef,
* onTransitionCleanup: () => {
* setTransitionPreview(null);
* setPendingTransitionComplete(false);
* },
* },
* fadeIn: {
* hasActiveTransition: Boolean(transitionPreview),
* },
* });
*
* // In JSX:
* <div
* className={isFadingIn ? 'animate-crossfade-in' : ''}
* onAnimationEnd={onFadeInAnimationEnd}
* >
*
* @example
* // Simple mode - direct navigation only (constructor)
* useBackgroundTransition({ pageSwitch });
*/
export function useBackgroundTransition({
pageSwitch,
fadeOut,
fadeIn,
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
const [isFadingIn, setIsFadingIn] = useState(false);
// Track previous isSwitching state to detect transition start
const wasSwitchingRef = useRef(false);
// Safari fallback timer ref - Safari's onAnimationEnd can be unreliable
const fadeInTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track if animation was already completed (by event or timer)
const fadeInCompletedRef = useRef(false);
// Track fadeIn config in ref to avoid stale closure issues
// This allows us to read current values without adding fadeIn to useLayoutEffect deps
const fadeInRef = useRef(fadeIn);
fadeInRef.current = fadeIn;
/**
* Reset fade-out state before starting a new transition.
* This prevents the fade-out effect from re-triggering when state resets.
*/
const resetFadeOut = useCallback(() => {
setIsOverlayFadingOut(false);
}, []);
/**
* Reset fade-in state (for cleanup or cancellation).
*/
const resetFadeIn = useCallback(() => {
setIsFadingIn(false);
fadeInCompletedRef.current = false;
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
}, []);
/**
* Complete fade-in animation.
* Called either by onAnimationEnd or by timer fallback.
* Uses ref to prevent double-completion.
*/
const completeFadeIn = useCallback(() => {
if (fadeInCompletedRef.current) return;
fadeInCompletedRef.current = true;
// Clear backup timer if it exists
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
setIsFadingIn(false);
}, []);
/**
* Handler for onAnimationEnd event.
* Called when CSS animation completes.
* Uses completeFadeIn to handle deduplication with timer fallback.
*/
const onFadeInAnimationEnd = useCallback(
(e?: React.AnimationEvent) => {
// Only handle the crossfade animation, not child animations
if (e && e.animationName !== 'page-crossfade-in') return;
completeFadeIn();
},
[completeFadeIn],
);
/**
* Effect: Fade out and remove transition overlay when background is ready.
* Only runs when fadeOut config is provided.
*
* Sequence:
* 1. Transition video finishes playing (pendingTransitionComplete = true)
* 2. New background image loads (isBackgroundReady = true)
* 3. Safari: Wait extra frame to ensure background is painted
* 4. Start fade-out animation (isOverlayFadingOut = true)
* 5. After fade completes, clean up video and clear transition state
*/
useEffect(() => {
if (!fadeOut) return;
const {
pendingTransitionComplete,
isBackgroundReady,
transitionVideoRef,
onTransitionCleanup,
} = fadeOut;
if (pendingTransitionComplete && isBackgroundReady && !isOverlayFadingOut) {
// Safari Black Flash Prevention:
// Wait an extra frame in Safari to ensure the new background is truly painted
// before starting the fade-out animation. This prevents showing black between
// the transition video and the new page content.
const startFadeOut = () => {
// Start fade-out animation
setIsOverlayFadingOut(true);
};
// Safari: verify paint completion with extra frame wait
if (isSafari()) {
scheduleAfterPaintSafari(startFadeOut);
} else {
startFadeOut();
}
}
}, [fadeOut, isOverlayFadingOut]);
/**
* Effect: Complete fade-out and cleanup after animation duration.
* Separated from the start effect to ensure proper cleanup timing.
*/
useEffect(() => {
if (!fadeOut || !isOverlayFadingOut) return;
const { transitionVideoRef, onTransitionCleanup } = fadeOut;
// After fade completes, remove the overlay
// Duration is read from CSS variable for consistency
const fadeTimer = setTimeout(() => {
const video = transitionVideoRef.current;
if (video) {
video.removeAttribute('src');
video.load();
}
// Clear previous background from shared hook
pageSwitch.clearPreviousBackground();
// Notify caller to clear transition state
onTransitionCleanup();
// Reset fade-out state
setIsOverlayFadingOut(false);
}, getCrossfadeDuration());
return () => clearTimeout(fadeTimer);
}, [fadeOut, isOverlayFadingOut, pageSwitch]);
/**
* Effect: Clear previous background overlay after fade completes (direct navigation).
*
* The previous background stays visible during the entire fade animation,
* providing a smooth crossfade effect. Only cleared after fade ends.
*
* IMPORTANT: Skip this for video transitions - the transition overlay handles
* the visual transition, and we'll clear the previous background manually
* after the overlay is removed.
*/
useEffect(() => {
// Read from ref to get current hasActiveTransition value
const hasActiveTransition = fadeInRef.current?.hasActiveTransition ?? false;
// Skip clearing during video transitions - let RuntimePresentation handle it
if (hasActiveTransition) return;
if (pageSwitch.isSwitching && pageSwitch.isNewBgReady && !isFadingIn) {
// Fade is complete - clear the previous background overlay
// This also resets isSwitching state so next navigation triggers fade-in
pageSwitch.clearPreviousBackground();
}
}, [
pageSwitch.isSwitching,
pageSwitch.isNewBgReady,
pageSwitch.clearPreviousBackground,
isFadingIn,
]);
/**
* Layout effect: Set up crossfade BEFORE browser paints when switching starts.
* useLayoutEffect runs synchronously after DOM mutations but before paint,
* preventing any flash of new content at full opacity.
*
* IMPORTANT: Skip this for transitions - transition video IS the effect.
*
* Cross-browser handling:
* - Sets up JS timer fallback for Safari (unreliable onAnimationEnd)
* - Chrome/Firefox rely on CSS onAnimationEnd event
*
* NOTE: We use fadeInRef to read current fadeIn config without adding it to deps.
* This prevents the effect from re-running on every render when the caller
* creates fadeIn config inline (which would reset wasSwitchingRef incorrectly).
*/
useLayoutEffect(() => {
// Read from ref to get latest value without triggering re-runs
const currentFadeIn = fadeInRef.current;
// Skip crossfade logic if fadeIn config was not provided
if (!currentFadeIn) {
wasSwitchingRef.current = pageSwitch.isSwitching;
return;
}
const justStartedSwitching =
pageSwitch.isSwitching && !wasSwitchingRef.current;
wasSwitchingRef.current = pageSwitch.isSwitching;
// Only start crossfade for NON-transition navigation
// Transitions use video overlay - no fade needed
if (justStartedSwitching && !currentFadeIn.hasActiveTransition) {
// Reset completion flag for new animation
fadeInCompletedRef.current = false;
// Clear any existing timer
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
setIsFadingIn(true);
// Safari/Firefox fallback: Use JS timer as backup since onAnimationEnd
// can be unreliable or fire on wrong animations.
// Timer is slightly longer than CSS duration to let CSS complete first.
// Chrome typically fires onAnimationEnd reliably, but timer is harmless backup.
const duration = getCrossfadeDuration();
// Add 50ms buffer for Safari's animation timing variance
const bufferMs = isSafari() ? 100 : 50;
fadeInTimerRef.current = setTimeout(() => {
fadeInTimerRef.current = null;
completeFadeIn();
}, duration + bufferMs);
}
// NOTE: fadeIn intentionally NOT in deps - we read from fadeInRef instead
// to avoid re-running when inline fadeIn object is recreated
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSwitch.isSwitching, completeFadeIn]);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (fadeInTimerRef.current) {
clearTimeout(fadeInTimerRef.current);
fadeInTimerRef.current = null;
}
};
}, []);
return {
isOverlayFadingOut,
resetFadeOut,
isFadingIn,
onFadeInAnimationEnd,
resetFadeIn,
};
}

View File

@ -1,103 +0,0 @@
/**
* useBackgroundUrls Hook
*
* Unified hook for resolving background display URLs.
* Used by both constructor and RuntimePresentation to ensure
* consistent URL resolution and timing.
*
* Priority:
* 1. Local storage paths (for constructor editing) resolved via blob cache
* 2. pageSwitch URLs (for navigation, already resolved)
*
* This ensures both views use the same logic for background URL resolution.
*/
import { useMemo } from 'react';
import type { UsePageSwitchResult } from './usePageSwitch';
/**
* Function type for resolving storage paths to display URLs
*/
export type UrlResolver = (storagePath: string) => string;
export interface UseBackgroundUrlsOptions {
/** Page switch hook result */
pageSwitch: Pick<
UsePageSwitchResult,
'currentBgImageUrl' | 'currentBgVideoUrl' | 'currentBgAudioUrl'
>;
/** URL resolver function (typically from preload orchestrator) */
resolveUrl: UrlResolver;
/** Local storage paths - used for constructor editing override */
localPaths?: {
imageUrl?: string;
videoUrl?: string;
audioUrl?: string;
};
}
export interface UseBackgroundUrlsResult {
/** Resolved display URL for background image */
backgroundImageSrc: string;
/** Resolved display URL for background video */
backgroundVideoSrc: string;
/** Resolved display URL for background audio */
backgroundAudioSrc: string;
}
/**
* Hook for unified background URL resolution.
*
* @example
* // Constructor - with local editing state
* const { backgroundImageSrc, backgroundVideoSrc, backgroundAudioSrc } = useBackgroundUrls({
* pageSwitch,
* resolveUrl: resolveUrlWithBlob,
* localPaths: {
* imageUrl: backgroundImageUrl,
* videoUrl: backgroundVideoUrl,
* audioUrl: backgroundAudioUrl,
* },
* });
*
* @example
* // RuntimePresentation - navigation only
* const { backgroundImageSrc, backgroundVideoSrc, backgroundAudioSrc } = useBackgroundUrls({
* pageSwitch,
* resolveUrl: resolveUrlWithBlob,
* });
*/
export function useBackgroundUrls({
pageSwitch,
resolveUrl,
localPaths,
}: UseBackgroundUrlsOptions): UseBackgroundUrlsResult {
// Memoize resolved URLs to avoid unnecessary recalculations
const backgroundImageSrc = useMemo(() => {
// Priority: local path (editing) > pageSwitch (navigation)
if (localPaths?.imageUrl) {
return resolveUrl(localPaths.imageUrl);
}
return pageSwitch.currentBgImageUrl;
}, [localPaths?.imageUrl, pageSwitch.currentBgImageUrl, resolveUrl]);
const backgroundVideoSrc = useMemo(() => {
if (localPaths?.videoUrl) {
return resolveUrl(localPaths.videoUrl);
}
return pageSwitch.currentBgVideoUrl;
}, [localPaths?.videoUrl, pageSwitch.currentBgVideoUrl, resolveUrl]);
const backgroundAudioSrc = useMemo(() => {
if (localPaths?.audioUrl) {
return resolveUrl(localPaths.audioUrl);
}
return pageSwitch.currentBgAudioUrl;
}, [localPaths?.audioUrl, pageSwitch.currentBgAudioUrl, resolveUrl]);
return {
backgroundImageSrc,
backgroundVideoSrc,
backgroundAudioSrc,
};
}

View File

@ -2,7 +2,7 @@
* useBackgroundVideoPlayback Hook * useBackgroundVideoPlayback Hook
* *
* Manages background video playback with custom start/end times. * Manages background video playback with custom start/end times.
* Follows patterns from useTransitionPlayback for video time control. * Built on top of video primitives for consistent behavior.
* *
* When loop is disabled, videos are tracked per-session so they only play once * When loop is disabled, videos are tracked per-session so they only play once
* and show the last frame on subsequent page visits (until browser refresh). * and show the last frame on subsequent page visits (until browser refresh).
@ -10,6 +10,7 @@
import { useEffect, useRef, useCallback, type RefObject } from 'react'; import { useEffect, useRef, useCallback, type RefObject } from 'react';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { useVideoEventManager } from './video/useVideoEventManager';
// Session-scoped tracking of videos that have finished playing (when loop=false) // Session-scoped tracking of videos that have finished playing (when loop=false)
// Key: videoUrl, cleared on browser refresh // Key: videoUrl, cleared on browser refresh
@ -30,6 +31,8 @@ export interface UseBackgroundVideoPlaybackOptions {
startTime?: number | null; startTime?: number | null;
/** End time in seconds (default: null = play to end) */ /** End time in seconds (default: null = play to end) */
endTime?: number | null; endTime?: number | null;
/** External pause control (e.g., during page transitions). Takes precedence over autoplay. */
paused?: boolean;
} }
export interface UseBackgroundVideoPlaybackResult { export interface UseBackgroundVideoPlaybackResult {
@ -66,6 +69,7 @@ export function useBackgroundVideoPlayback({
muted = true, muted = true,
startTime = null, startTime = null,
endTime = null, endTime = null,
paused = false,
}: UseBackgroundVideoPlaybackOptions): UseBackgroundVideoPlaybackResult { }: UseBackgroundVideoPlaybackOptions): UseBackgroundVideoPlaybackResult {
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
@ -76,31 +80,25 @@ export function useBackgroundVideoPlayback({
// Block autoplay if video already played this session (only when loop=false) // Block autoplay if video already played this session (only when loop=false)
const shouldBlockAutoplay = const shouldBlockAutoplay =
!loop && trackingKey ? playedVideos.has(trackingKey) : false; !loop && trackingKey ? playedVideos.has(trackingKey) : false;
// Store current values in refs for event handlers to access // Store current values in refs for event handlers to access
const startTimeRef = useRef(startTime); const startTimeRef = useRef(startTime);
const endTimeRef = useRef(endTime); const endTimeRef = useRef(endTime);
const loopRef = useRef(loop); const loopRef = useRef(loop);
const autoplayRef = useRef(autoplay); const pausedRef = useRef(paused);
const trackingKeyRef = useRef(trackingKey);
// Update refs when values change // Update refs when values change
useEffect(() => { useEffect(() => {
startTimeRef.current = startTime; startTimeRef.current = startTime;
}, [startTime]);
useEffect(() => {
endTimeRef.current = endTime; endTimeRef.current = endTime;
}, [endTime]);
useEffect(() => {
loopRef.current = loop; loopRef.current = loop;
}, [loop]); pausedRef.current = paused;
trackingKeyRef.current = trackingKey;
}, [startTime, endTime, loop, paused, trackingKey]);
useEffect(() => { // Seek to start time when video metadata is loaded
autoplayRef.current = autoplay; const handleLoadedMetadata = useCallback(() => {
}, [autoplay]);
// Seek to start time when specified and video is ready
const seekToStartTime = useCallback(() => {
const video = videoRef.current; const video = videoRef.current;
const st = startTimeRef.current; const st = startTimeRef.current;
if (!video || st == null || st <= 0) return; if (!video || st == null || st <= 0) return;
@ -109,6 +107,57 @@ export function useBackgroundVideoPlayback({
logger.info('Background video: seeking to start time', { startTime: st }); logger.info('Background video: seeking to start time', { startTime: st });
}, []); }, []);
// Handle time update for end time enforcement
const handleTimeUpdate = useCallback(() => {
const video = videoRef.current;
if (!video || pausedRef.current) return;
const currentEndTime = endTimeRef.current;
const currentLoop = loopRef.current;
const currentStartTime = startTimeRef.current;
// Skip if no end time is set
if (currentEndTime == null) return;
// Check if we've reached or passed the end time
if (video.currentTime >= currentEndTime) {
if (currentLoop) {
// Loop back to start time (or beginning)
const loopTarget = currentStartTime ?? 0;
video.currentTime = loopTarget;
logger.info('Background video: looping back', {
from: currentEndTime,
to: loopTarget,
});
} else {
// Pause at end time
video.pause();
logger.info('Background video: paused at end time', {
endTime: currentEndTime,
});
}
}
}, []);
// Handle video ended for play-once tracking
const handleEnded = useCallback(() => {
const key = trackingKeyRef.current;
if (key && !loopRef.current) {
playedVideos.add(key);
}
}, []);
// Use video event manager for event handling
useVideoEventManager({
videoRef,
enabled: Boolean(videoUrl),
handlers: {
onLoadedMetadata: handleLoadedMetadata,
onTimeUpdate: handleTimeUpdate,
onEnded: handleEnded,
},
});
// Handle start time changes - seek immediately when startTime changes // Handle start time changes - seek immediately when startTime changes
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
@ -119,23 +168,19 @@ export function useBackgroundVideoPlayback({
video.currentTime = startTime; video.currentTime = startTime;
logger.info('Background video: seeking to start time', { startTime }); logger.info('Background video: seeking to start time', { startTime });
} }
}, [videoUrl, startTime]);
// Set up listener for initial load (if not loaded yet) // Handle autoplay state changes (respects external pause control)
const handleLoadedMetadata = () => {
seekToStartTime();
};
video.addEventListener('loadedmetadata', handleLoadedMetadata);
return () => {
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
};
}, [videoUrl, startTime, seekToStartTime]);
// Handle autoplay state changes
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if (!video || !videoUrl) return; if (!video || !videoUrl) return;
// External pause takes precedence over autoplay
if (paused) {
video.pause();
return;
}
if (autoplay) { if (autoplay) {
video.play().catch((error) => { video.play().catch((error) => {
// Autoplay blocked by browser - this is expected behavior // Autoplay blocked by browser - this is expected behavior
@ -146,46 +191,7 @@ export function useBackgroundVideoPlayback({
} else { } else {
video.pause(); video.pause();
} }
}, [videoUrl, autoplay]); }, [videoUrl, autoplay, paused]);
// Handle end time enforcement via timeupdate
useEffect(() => {
const video = videoRef.current;
if (!video || !videoUrl) return;
const handleTimeUpdate = () => {
const currentEndTime = endTimeRef.current;
const currentLoop = loopRef.current;
const currentStartTime = startTimeRef.current;
// Skip if no end time is set
if (currentEndTime == null) return;
// Check if we've reached or passed the end time
if (video.currentTime >= currentEndTime) {
if (currentLoop) {
// Loop back to start time (or beginning)
const loopTarget = currentStartTime ?? 0;
video.currentTime = loopTarget;
logger.info('Background video: looping back', {
from: currentEndTime,
to: loopTarget,
});
} else {
// Pause at end time
video.pause();
logger.info('Background video: paused at end time', {
endTime: currentEndTime,
});
}
}
};
video.addEventListener('timeupdate', handleTimeUpdate);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
};
}, [videoUrl]);
// Handle muted state changes // Handle muted state changes
useEffect(() => { useEffect(() => {
@ -214,13 +220,6 @@ export function useBackgroundVideoPlayback({
} }
return; return;
} }
// Mark video as played when it ends
const handleEnded = () => {
playedVideos.add(trackingKey);
};
video.addEventListener('ended', handleEnded);
return () => video.removeEventListener('ended', handleEnded);
}, [videoUrl, trackingKey, loop]); }, [videoUrl, trackingKey, loop]);
return { videoRef, shouldBlockAutoplay }; return { videoRef, shouldBlockAutoplay };

View File

@ -28,7 +28,7 @@ const EMPTY_PAGES: TourPage[] = [];
const EMPTY_ASSETS: Asset[] = []; const EMPTY_ASSETS: Asset[] = [];
interface UseConstructorDataResult { interface UseConstructorDataResult {
// Project // Project (note: transition_settings now fetched separately via project_transition_settings store)
project: { project: {
name: string; name: string;
design_width?: number; design_width?: number;

View File

@ -80,8 +80,8 @@ interface UseConstructorPageActionsResult {
saveConstructor: () => Promise<void>; saveConstructor: () => Promise<void>;
/** Save dev content to stage environment */ /** Save dev content to stage environment */
saveToStage: () => Promise<void>; saveToStage: () => Promise<void>;
/** Create a new page */ /** Create a new page with the given name and slug */
createPage: () => Promise<void>; createPage: (pageName: string, slug: string) => Promise<void>;
/** Create a transition (legacy - transitions are now stored on elements) */ /** Create a transition (legacy - transitions are now stored on elements) */
createTransition: (params: { createTransition: (params: {
name?: string; name?: string;
@ -256,31 +256,40 @@ export function useConstructorPageActions({
} }
}, [projectId, saveConstructor, onError, onSuccess]); }, [projectId, saveConstructor, onError, onSuccess]);
const createPage = useCallback(async () => { const createPage = useCallback(async (pageName: string, slug: string) => {
if (!projectId) { if (!projectId) {
onError?.('Project is required.'); onError?.('Project is required.');
return; return;
} }
if (!pageName.trim()) {
onError?.('Page name is required.');
return;
}
if (!slug.trim()) {
onError?.('Page slug is required.');
return;
}
const maxSortOrder = Math.max( const maxSortOrder = Math.max(
0, 0,
...pages.map((item) => Number(item.sort_order || 0)), ...pages.map((item) => Number(item.sort_order || 0)),
); );
const nextPageNumber = pages.length + 1;
const payload = { const payload = {
project: projectId, project: projectId,
environment: activePage?.environment || 'dev', environment: activePage?.environment || 'dev',
source_key: '', source_key: '',
name: `Page ${nextPageNumber}`, name: pageName.trim(),
slug: `page-${nextPageNumber}-${Date.now().toString().slice(-4)}`, slug: slug.trim(),
sort_order: maxSortOrder + 1, sort_order: maxSortOrder + 1,
background_image_url: '', background_image_url: '',
background_video_url: '', background_video_url: '',
background_audio_url: '', background_audio_url: '',
background_loop: false, background_loop: false,
requires_auth: false, requires_auth: false,
ui_schema_json: JSON.stringify({ elements: [] }), ui_schema_json: { elements: [] },
// Copy project design dimensions to new page // Copy project design dimensions to new page
design_width: project?.design_width ?? null, design_width: project?.design_width ?? null,
design_height: project?.design_height ?? null, design_height: project?.design_height ?? null,

View File

@ -1,233 +0,0 @@
/**
* useNeighborGraph Hook
*
* Builds a navigation graph from page_links to determine which pages
* are neighbors and should have their assets preloaded.
*
* Uses shared asset discovery from lib/assetCache for consistent extraction.
*/
import { useMemo } from 'react';
import { PRELOAD_CONFIG } from '../config/preload.config';
import {
extractElementAssets,
extractPageBackgroundAssets,
extractTransitionAssets,
toPreloadAssetInfo,
} from '../lib/assetCache';
import type {
PreloadPage,
PreloadPageLink,
PreloadElement,
PreloadAssetInfo,
PreloadNeighborInfo,
} from '../types/preload';
interface UseNeighborGraphOptions {
pages: PreloadPage[];
pageLinks: PreloadPageLink[];
elements: PreloadElement[];
maxDepth?: number;
}
interface NeighborGraphResult {
/**
* Get neighboring page IDs within maxDepth hops
*/
getNeighbors: (
currentPageId: string,
maxDepth?: number,
) => PreloadNeighborInfo[];
/**
* Get all assets that should be preloaded for given pages
*/
getAssetsForPages: (pageIds: string[]) => PreloadAssetInfo[];
/**
* Get prioritized assets for preloading based on current page
*/
getPrioritizedAssets: (
currentPageId: string,
maxDepth?: number,
) => PreloadAssetInfo[];
/**
* Raw adjacency list for debugging
*/
adjacencyList: Map<string, string[]>;
}
export function useNeighborGraph(
options: UseNeighborGraphOptions,
): NeighborGraphResult {
const {
pages,
pageLinks,
elements,
maxDepth = PRELOAD_CONFIG.neighborGraph.maxDepth,
} = options;
// Build adjacency list from page links
const adjacencyList = useMemo(() => {
const adj = new Map<string, string[]>();
// Initialize all pages
pages.forEach((page) => {
adj.set(page.id, []);
});
// Add edges from active page links
const activeLinks = pageLinks.filter((link) => link.is_active !== false);
activeLinks.forEach((link) => {
if (link.from_pageId && link.to_pageId) {
const neighbors = adj.get(link.from_pageId) || [];
if (!neighbors.includes(link.to_pageId)) {
neighbors.push(link.to_pageId);
adj.set(link.from_pageId, neighbors);
}
}
});
return adj;
}, [pages, pageLinks]);
// BFS to find neighbors within depth
const getNeighbors = useMemo(() => {
return (currentPageId: string, depth = maxDepth): PreloadNeighborInfo[] => {
const visited = new Set<string>();
const result: PreloadNeighborInfo[] = [];
const queue: { pageId: string; distance: number }[] = [
{ pageId: currentPageId, distance: 0 },
];
visited.add(currentPageId);
while (queue.length > 0) {
const item = queue.shift();
if (!item) break;
const { pageId, distance } = item;
if (distance > 0) {
result.push({ pageId, distance });
}
if (distance < depth) {
const neighbors = adjacencyList.get(pageId) || [];
for (const neighborId of neighbors) {
if (!visited.has(neighborId)) {
visited.add(neighborId);
queue.push({ pageId: neighborId, distance: distance + 1 });
}
}
}
}
// Sort by distance (closest first)
return result.sort((a, b) => a.distance - b.distance);
};
}, [adjacencyList, maxDepth]);
// Get assets for a set of pages - uses shared extraction from assetDiscovery
const getAssetsForPages = useMemo(() => {
return (pageIds: string[]): PreloadAssetInfo[] => {
const assets: PreloadAssetInfo[] = [];
const seenUrls = new Set<string>();
pageIds.forEach((pageId) => {
// Find the page to get its background assets
const page = pages.find((p) => p.id === pageId);
if (page) {
// Use shared extraction for page backgrounds
const bgAssets = extractPageBackgroundAssets(page);
bgAssets.forEach((asset) => {
if (!seenUrls.has(asset.originalUrl)) {
seenUrls.add(asset.originalUrl);
assets.push(toPreloadAssetInfo(asset));
}
});
}
// Get elements for this page and use shared extraction
const pageElements = elements.filter((el) => el.pageId === pageId);
const elementAssets = extractElementAssets(pageElements, pageId);
elementAssets.forEach((asset) => {
if (!seenUrls.has(asset.originalUrl)) {
seenUrls.add(asset.originalUrl);
assets.push(toPreloadAssetInfo(asset));
}
});
});
// Extract transition videos using shared extraction
pageIds.forEach((pageId) => {
const transitionAssets = extractTransitionAssets(pageLinks, pageId);
transitionAssets.forEach((asset) => {
if (!seenUrls.has(asset.originalUrl)) {
seenUrls.add(asset.originalUrl);
assets.push(toPreloadAssetInfo(asset));
}
});
});
return assets;
};
}, [pages, elements, pageLinks]);
// Get prioritized assets for preloading
const getPrioritizedAssets = useMemo(() => {
return (currentPageId: string, depth = maxDepth): PreloadAssetInfo[] => {
// Get current page assets (highest priority)
const currentPageAssets = getAssetsForPages([currentPageId]).map(
(asset) => ({
...asset,
priority:
PRELOAD_CONFIG.priority.currentPage +
(PRELOAD_CONFIG.priority.assetType[asset.assetType] || 0),
}),
);
// Get neighbor page assets
const neighbors = getNeighbors(currentPageId, depth);
const neighborAssets: PreloadAssetInfo[] = [];
neighbors.forEach(({ pageId, distance }) => {
const assets = getAssetsForPages([pageId]);
assets.forEach((asset) => {
const basePriority = PRELOAD_CONFIG.priority.neighborBase / distance;
const typePriority =
PRELOAD_CONFIG.priority.assetType[asset.assetType] || 0;
neighborAssets.push({
...asset,
priority: basePriority + typePriority,
});
});
});
// Combine and sort by priority (highest first)
const allAssets = [...currentPageAssets, ...neighborAssets];
// Deduplicate by URL, keeping highest priority
const urlToPriority = new Map<string, PreloadAssetInfo>();
allAssets.forEach((asset) => {
const existing = urlToPriority.get(asset.url);
if (!existing || asset.priority > existing.priority) {
urlToPriority.set(asset.url, asset);
}
});
return Array.from(urlToPriority.values()).sort(
(a, b) => b.priority - a.priority,
);
};
}, [getAssetsForPages, getNeighbors, maxDepth]);
return {
getNeighbors,
getAssetsForPages,
getPrioritizedAssets,
adjacencyList,
};
}

View File

@ -1,14 +1,12 @@
/** /**
* useNetworkAware Hook * useNetworkAware Hook
* *
* Monitors network conditions and adapts preloading strategy accordingly. * Monitors network conditions and adapts behavior accordingly.
* Uses the Network Information API where available.
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import type { NetworkInfo } from '../types/offline'; import type { NetworkInfo } from '../types/offline';
// Extend Navigator interface for Network Information API
interface NetworkInformation extends EventTarget { interface NetworkInformation extends EventTarget {
readonly effectiveType?: 'slow-2g' | '2g' | '3g' | '4g'; readonly effectiveType?: 'slow-2g' | '2g' | '3g' | '4g';
readonly downlink?: number; readonly downlink?: number;
@ -25,22 +23,11 @@ interface NavigatorWithConnection extends Navigator {
interface UseNetworkAwareResult { interface UseNetworkAwareResult {
networkInfo: NetworkInfo; networkInfo: NetworkInfo;
/**
* Whether preloading should be aggressive (good connection)
*/
shouldPreloadAggressively: boolean; shouldPreloadAggressively: boolean;
/**
* Whether to prefer lower quality variants
*/
preferLowQuality: boolean; preferLowQuality: boolean;
/**
* Recommended concurrent download count based on network
*/
recommendedConcurrency: number; recommendedConcurrency: number;
/**
* Whether offline mode should be suggested to user
*/
suggestOfflineMode: boolean; suggestOfflineMode: boolean;
shouldUseVideoTransitions: boolean;
} }
const getConnection = (): NetworkInformation | null => { const getConnection = (): NetworkInformation | null => {
@ -68,7 +55,6 @@ const getNetworkInfo = (): NetworkInfo => {
export function useNetworkAware(): UseNetworkAwareResult { export function useNetworkAware(): UseNetworkAwareResult {
const [networkInfo, setNetworkInfo] = useState<NetworkInfo>(getNetworkInfo); const [networkInfo, setNetworkInfo] = useState<NetworkInfo>(getNetworkInfo);
// Update network info on changes
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@ -76,11 +62,9 @@ export function useNetworkAware(): UseNetworkAwareResult {
setNetworkInfo(getNetworkInfo()); setNetworkInfo(getNetworkInfo());
}; };
// Listen for online/offline events
window.addEventListener('online', updateNetworkInfo); window.addEventListener('online', updateNetworkInfo);
window.addEventListener('offline', updateNetworkInfo); window.addEventListener('offline', updateNetworkInfo);
// Listen for connection changes if available
const connection = getConnection(); const connection = getConnection();
if (connection) { if (connection) {
connection.addEventListener('change', updateNetworkInfo); connection.addEventListener('change', updateNetworkInfo);
@ -95,36 +79,28 @@ export function useNetworkAware(): UseNetworkAwareResult {
}; };
}, []); }, []);
// Determine if preloading should be aggressive
const shouldPreloadAggressively = useCallback((): boolean => { const shouldPreloadAggressively = useCallback((): boolean => {
if (!networkInfo.isOnline) return false; if (!networkInfo.isOnline) return false;
if (networkInfo.saveData) return false; if (networkInfo.saveData) return false;
// Good connection: 4g or high downlink
if (networkInfo.effectiveType === '4g') return true; if (networkInfo.effectiveType === '4g') return true;
if (networkInfo.downlink && networkInfo.downlink >= 5) return true; if (networkInfo.downlink && networkInfo.downlink >= 5) return true;
return false; return false;
}, [networkInfo]); }, [networkInfo]);
// Determine if low quality variants should be preferred
const preferLowQuality = useCallback((): boolean => { const preferLowQuality = useCallback((): boolean => {
if (networkInfo.saveData) return true; if (networkInfo.saveData) return true;
if (networkInfo.effectiveType === 'slow-2g') return true; if (networkInfo.effectiveType === 'slow-2g') return true;
if (networkInfo.effectiveType === '2g') return true; if (networkInfo.effectiveType === '2g') return true;
if (networkInfo.downlink && networkInfo.downlink < 1) return true; if (networkInfo.downlink && networkInfo.downlink < 1) return true;
return false; return false;
}, [networkInfo]); }, [networkInfo]);
// Calculate recommended concurrency
const getRecommendedConcurrency = useCallback((): number => { const getRecommendedConcurrency = useCallback((): number => {
if (!networkInfo.isOnline) return 0; if (!networkInfo.isOnline) return 0;
if (networkInfo.saveData) return 1; if (networkInfo.saveData) return 1;
switch (networkInfo.effectiveType) { switch (networkInfo.effectiveType) {
case 'slow-2g': case 'slow-2g':
return 1;
case '2g': case '2g':
return 1; return 1;
case '3g': case '3g':
@ -132,32 +108,40 @@ export function useNetworkAware(): UseNetworkAwareResult {
case '4g': case '4g':
return 3; return 3;
default: default:
// Fall back to downlink-based calculation
if (networkInfo.downlink) { if (networkInfo.downlink) {
if (networkInfo.downlink < 1) return 1; if (networkInfo.downlink < 1) return 1;
if (networkInfo.downlink < 5) return 2; if (networkInfo.downlink < 5) return 2;
return 3; return 3;
} }
return 2; // Default return 2;
} }
}, [networkInfo]); }, [networkInfo]);
// Determine if offline mode should be suggested
const suggestOfflineMode = useCallback((): boolean => { const suggestOfflineMode = useCallback((): boolean => {
// Suggest offline if on poor connection
if (networkInfo.effectiveType === 'slow-2g') return true; if (networkInfo.effectiveType === 'slow-2g') return true;
if (networkInfo.effectiveType === '2g') return true; if (networkInfo.effectiveType === '2g') return true;
if (networkInfo.rtt && networkInfo.rtt > 500) return true; if (networkInfo.rtt && networkInfo.rtt > 500) return true;
if (networkInfo.downlink && networkInfo.downlink < 0.5) return true; if (networkInfo.downlink && networkInfo.downlink < 0.5) return true;
return false; return false;
}, [networkInfo]); }, [networkInfo]);
const shouldUseVideoTransitions = useCallback((): boolean => {
if (!networkInfo.isOnline) return false;
if (networkInfo.saveData) return false;
if (networkInfo.effectiveType === 'slow-2g') return false;
if (networkInfo.effectiveType === '2g') return false;
if (networkInfo.effectiveType === '3g') return false;
if (networkInfo.downlink !== undefined && networkInfo.downlink < 2) return false;
if (networkInfo.rtt !== undefined && networkInfo.rtt > 500) return false;
return true;
}, [networkInfo]);
return { return {
networkInfo, networkInfo,
shouldPreloadAggressively: shouldPreloadAggressively(), shouldPreloadAggressively: shouldPreloadAggressively(),
preferLowQuality: preferLowQuality(), preferLowQuality: preferLowQuality(),
recommendedConcurrency: getRecommendedConcurrency(), recommendedConcurrency: getRecommendedConcurrency(),
suggestOfflineMode: suggestOfflineMode(), suggestOfflineMode: suggestOfflineMode(),
shouldUseVideoTransitions: shouldUseVideoTransitions(),
}; };
} }

View File

@ -420,7 +420,6 @@ export function useOfflineMode(
? 50 ? 50
: 75, : 75,
storageKey: asset.storageKey, storageKey: asset.storageKey,
createBlobUrl: true, // Create blob URL for instant display
persist: true, // Persist for resume after page refresh persist: true, // Persist for resume after page refresh
}) })
.catch((err) => { .catch((err) => {

View File

@ -0,0 +1,867 @@
/**
* usePageNavigationState Hook
*
* Unified state machine for page navigation, replacing 6+ fragmented hooks.
* Uses useReducer for atomic state transitions, preventing race conditions.
*
* Consolidates:
* - usePageSwitch: URL resolution and switching
* - useBackgroundState: Background ready tracking
* - useBackgroundTransition: Fade-from-black effects
* - useTransitionCleanup: Video cleanup coordination
* - useBackgroundUrls: URL resolution for display
* - pageLoadingUtils: Loading state computation
*
* State Machine Phases:
* - idle: No navigation in progress, elements visible
* - preparing: Navigation triggered, saving previous URLs, resolving new URLs
* - transitioning: Video transition playing
* - transition_done: Video finished, waiting for background to load
* - loading_bg: Direct navigation (no video), waiting for background to load
* - fading_in: Black overlay fading out to reveal new page
*/
import { useReducer, useCallback, useRef, useEffect, useMemo } from 'react';
import {
resolveAssetPlaybackUrl,
markPresignedUrlFailed,
isRelativeStoragePath,
isPresignedUrl,
buildProxyUrl,
} from '../lib/assetUrl';
import {
scheduleAfterPaint,
scheduleAfterPaintSafari,
isSafari,
getCrossfadeDuration,
} from '../lib/browserUtils';
import { logger } from '../lib/logger';
import type { ResolvedTransitionSettings } from '../types/transition';
// ============================================================================
// Types
// ============================================================================
/**
* Navigation phases as a finite state machine
*/
export type NavigationPhase =
| 'idle' // No navigation in progress
| 'preparing' // Resolving URLs, saving previous state
| 'transitioning' // Video transition playing
| 'transition_done' // Video finished, waiting for background
| 'loading_bg' // Direct navigation, waiting for background
| 'fading_in'; // Black overlay fading out
/**
* Minimal page interface for navigation
*/
export interface NavigablePage {
id: string;
background_image_url?: string;
background_video_url?: string;
background_audio_url?: string;
}
/**
* Preload cache provider interface
*/
export interface PreloadCacheProvider {
getReadyBlobUrl?: (url: string) => string | null;
getCachedBlobUrl?: (url: string) => Promise<string | null>;
preloadedUrls?: Set<string>;
}
/**
* Internal state structure
*/
interface NavigationState {
phase: NavigationPhase;
// Current page URLs (resolved for display)
currentImageUrl: string;
currentVideoUrl: string;
currentAudioUrl: string;
// Previous page URLs (for overlay during transition)
previousImageUrl: string;
previousVideoUrl: string;
// Target page ID (during navigation)
targetPageId: string | null;
// Whether current navigation is a back navigation
isBackNavigation: boolean;
// Safari black flash prevention
lastKnownBgUrl: string;
// Video buffering state
isVideoBuffering: boolean;
}
/**
* Actions for the reducer
*/
type NavigationAction =
| {
type: 'START_NAVIGATION';
payload: {
hasTransition: boolean;
targetPageId: string | null;
isBack: boolean;
};
}
| {
type: 'START_TRANSITION';
payload: {
targetPageId: string | null;
isBack: boolean;
};
}
| {
type: 'URLS_RESOLVED';
payload: {
imageUrl: string;
videoUrl: string;
audioUrl: string;
};
}
| { type: 'TRANSITION_STARTED' }
| { type: 'TRANSITION_ENDED' }
| { type: 'BACKGROUND_READY' }
| { type: 'FADE_STARTED' }
| { type: 'FADE_COMPLETED' }
| {
type: 'SET_BACKGROUND_DIRECTLY';
payload: {
imageUrl: string;
videoUrl: string;
audioUrl: string;
};
}
| { type: 'RESET_TO_IDLE' }
| { type: 'SET_VIDEO_BUFFERING'; payload: boolean }
| { type: 'UPDATE_LAST_KNOWN_BG'; payload: string }
| { type: 'CLEAR_PREVIOUS_BACKGROUND' };
// ============================================================================
// Reducer
// ============================================================================
const initialState: NavigationState = {
phase: 'idle',
currentImageUrl: '',
currentVideoUrl: '',
currentAudioUrl: '',
previousImageUrl: '',
previousVideoUrl: '',
targetPageId: null,
isBackNavigation: false,
lastKnownBgUrl: '',
isVideoBuffering: false,
};
function navigationReducer(
state: NavigationState,
action: NavigationAction,
): NavigationState {
// DevTools logging in development
if (process.env.NODE_ENV === 'development') {
logger.info('[NavigationState] Action:', {
type: action.type,
currentPhase: state.phase,
payload: 'payload' in action ? action.payload : undefined,
});
}
switch (action.type) {
case 'START_NAVIGATION':
// ATOMIC: Save previous URLs + set phase in one update
return {
...state,
phase: action.payload.hasTransition ? 'transitioning' : 'loading_bg',
previousImageUrl: state.currentImageUrl,
previousVideoUrl: state.currentVideoUrl,
targetPageId: action.payload.targetPageId,
isBackNavigation: action.payload.isBack,
};
case 'START_TRANSITION':
// Video transition started (before video plays)
// Sets phase to 'transitioning' and saves previous URLs atomically
return {
...state,
phase: 'transitioning',
previousImageUrl: state.currentImageUrl,
previousVideoUrl: state.currentVideoUrl,
targetPageId: action.payload.targetPageId,
isBackNavigation: action.payload.isBack,
};
case 'URLS_RESOLVED':
// URLs resolved, update current URLs
return {
...state,
currentImageUrl: action.payload.imageUrl,
currentVideoUrl: action.payload.videoUrl,
currentAudioUrl: action.payload.audioUrl,
};
case 'TRANSITION_STARTED':
// Video transition has started playing
if (state.phase !== 'transitioning') return state;
return state; // Phase already correct, no change needed
case 'TRANSITION_ENDED':
// Video transition ended, wait for background
if (state.phase !== 'transitioning') return state;
return {
...state,
phase: 'transition_done',
};
case 'BACKGROUND_READY':
// Background loaded, start fade-in (only from certain phases)
if (
state.phase !== 'transition_done' &&
state.phase !== 'loading_bg' &&
state.phase !== 'preparing'
) {
return state;
}
return {
...state,
phase: 'fading_in',
};
case 'FADE_STARTED':
// Fade animation started
if (state.phase !== 'fading_in') return state;
return state;
case 'FADE_COMPLETED':
// Fade animation completed, return to idle
return {
...state,
phase: 'idle',
previousImageUrl: '',
previousVideoUrl: '',
targetPageId: null,
isBackNavigation: false,
};
case 'SET_BACKGROUND_DIRECTLY':
// Direct background update (edit mode) - bypasses navigation flow
return {
...state,
phase: 'idle',
currentImageUrl: action.payload.imageUrl,
currentVideoUrl: action.payload.videoUrl,
currentAudioUrl: action.payload.audioUrl,
previousImageUrl: '',
previousVideoUrl: '',
};
case 'RESET_TO_IDLE':
// Force reset to idle state
return {
...state,
phase: 'idle',
previousImageUrl: '',
previousVideoUrl: '',
targetPageId: null,
isBackNavigation: false,
};
case 'SET_VIDEO_BUFFERING':
return {
...state,
isVideoBuffering: action.payload,
};
case 'UPDATE_LAST_KNOWN_BG':
// Update Safari black flash prevention snapshot
return {
...state,
lastKnownBgUrl: action.payload,
};
case 'CLEAR_PREVIOUS_BACKGROUND':
return {
...state,
previousImageUrl: '',
previousVideoUrl: '',
};
default:
return state;
}
}
// ============================================================================
// Hook Options & Result
// ============================================================================
export interface UsePageNavigationStateOptions {
/** Preload cache provider for blob URL resolution */
preloadCache?: PreloadCacheProvider;
/** Fade duration in milliseconds (default: 700) */
fadeDurationMs?: number;
/** Transition settings for dynamic duration/easing */
transitionSettings?: ResolvedTransitionSettings | null;
}
export interface UsePageNavigationStateResult {
// Current state
phase: NavigationPhase;
state: NavigationState;
// Current page URLs (for display)
currentImageUrl: string;
currentVideoUrl: string;
currentAudioUrl: string;
// Previous page URLs (for overlay)
previousImageUrl: string;
previousVideoUrl: string;
// Safari black flash prevention
lastKnownBgUrl: string;
// Derived states (computed from phase)
isLoading: boolean;
showSpinner: boolean;
showElements: boolean;
showPreviousOverlay: boolean;
showTransitionVideo: boolean;
isFadingIn: boolean;
isSwitching: boolean;
isNewBgReady: boolean;
isBackgroundReady: boolean;
pendingTransitionComplete: boolean;
// Video buffering state
isVideoBuffering: boolean;
// Transition style for CSS
transitionStyle: React.CSSProperties;
// Actions
/** Start navigation to a new page */
navigateToPage: (
targetPage: NavigablePage | null,
options?: {
hasTransition?: boolean;
isBack?: boolean;
onSwitched?: () => void;
},
) => Promise<void>;
/** Signal that background media is ready (call from CanvasBackground.onLoad) */
onBackgroundReady: () => void;
/** Signal that transition video has ended */
onTransitionEnded: () => void;
/** Reset background ready state (call before navigation) */
resetBackgroundReady: () => void;
/** Clear previous background overlay */
clearPreviousBackground: () => void;
/** Direct background update for edit mode (bypasses navigation flow) */
setBackgroundDirectly: (
imageUrl: string,
videoUrl: string,
audioUrl: string,
) => void;
/** Reset to idle state */
resetToIdle: () => void;
/** Video buffering state callback */
onVideoBufferStateChange: (isBuffering: boolean) => void;
/** Mark new background as ready (for compatibility with usePageSwitch) */
markBackgroundReady: () => void;
/** Start a video transition (sets phase to 'transitioning') */
startTransition: (targetPageId: string | null, isBack?: boolean) => void;
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Decode an image from URL to ensure it's ready for display.
* Safari-specific: waits extra frame after decode to ensure pixels are painted.
*/
const decodeImage = (url: string): Promise<void> => {
return new Promise((resolve) => {
if (!url) {
resolve();
return;
}
const img = new window.Image();
const safariMode = isSafari();
const onReady = () => {
if (safariMode) {
scheduleAfterPaintSafari(() => resolve());
} else {
scheduleAfterPaint(() => resolve());
}
};
img.onload = () => {
if (typeof img.decode === 'function') {
img.decode().then(onReady).catch(onReady);
} else {
onReady();
}
};
img.onerror = () => onReady();
img.src = url;
});
};
/**
* Load and decode an image with presigned URL fallback.
*/
const loadImageWithFallback = (
url: string,
storageKey?: string,
): Promise<string> => {
return new Promise((resolve) => {
const img = new window.Image();
const safariMode = isSafari();
const onImageReady = (srcUrl: string) => {
if (safariMode) {
scheduleAfterPaintSafari(() => resolve(srcUrl));
} else {
resolve(srcUrl);
}
};
const tryLoad = (srcUrl: string, isRetry = false) => {
img.src = srcUrl;
img.onload = () => {
if (typeof img.decode === 'function') {
img
.decode()
.then(() => onImageReady(srcUrl))
.catch(() => onImageReady(srcUrl));
} else {
onImageReady(srcUrl);
}
};
img.onerror = () => {
if (!isRetry && isPresignedUrl(srcUrl) && storageKey) {
logger.info('Image presigned URL failed, retrying with proxy', {
storageKey: storageKey.slice(-50),
});
markPresignedUrlFailed(storageKey);
const proxyUrl = buildProxyUrl(storageKey);
tryLoad(proxyUrl, true);
} else {
onImageReady(srcUrl);
}
};
};
tryLoad(url);
});
};
// ============================================================================
// Main Hook
// ============================================================================
export function usePageNavigationState(
options: UsePageNavigationStateOptions = {},
): UsePageNavigationStateResult {
const { preloadCache, transitionSettings } = options;
const fadeDurationMs =
options.fadeDurationMs ?? transitionSettings?.durationMs ?? 700;
const [state, dispatch] = useReducer(navigationReducer, initialState);
// Refs for stable callbacks
const preloadCacheRef = useRef(preloadCache);
preloadCacheRef.current = preloadCache;
const transitionSettingsRef = useRef(transitionSettings);
transitionSettingsRef.current = transitionSettings;
// Track created blob URLs for cleanup
const createdBlobUrlsRef = useRef<Set<string>>(new Set());
// Fade timer ref
const fadeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// ============================================================================
// URL Resolution
// ============================================================================
/**
* Resolve a storage path to a displayable URL.
*/
const resolveToDisplayUrl = useCallback(
async (storagePath: string | undefined): Promise<string> => {
if (!storagePath) return '';
const cache = preloadCacheRef.current;
// 1. Try in-memory blob URL lookup (instant)
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(storagePath);
if (readyUrl) {
logger.info('Using ready blob URL (storage key)', {
storagePath: storagePath.slice(-50),
});
return readyUrl;
}
}
// 2. Try persistent cache by storage path
if (cache?.getCachedBlobUrl) {
try {
const blobUrl = await cache.getCachedBlobUrl(storagePath);
if (blobUrl) {
createdBlobUrlsRef.current.add(blobUrl);
return blobUrl;
}
} catch {
// Fall through
}
}
// 3. Resolve to playback URL
const originalUrl = resolveAssetPlaybackUrl(storagePath);
if (!originalUrl) return '';
// Try blob URL lookup by resolved URL
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(originalUrl);
if (readyUrl) {
return readyUrl;
}
}
// Try cached blob URL by resolved URL
if (cache?.getCachedBlobUrl) {
try {
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
if (blobUrl) {
createdBlobUrlsRef.current.add(blobUrl);
return blobUrl;
}
} catch {
// Fall through
}
}
// Load with presigned URL fallback
const storageKey = isRelativeStoragePath(storagePath)
? storagePath
: undefined;
return loadImageWithFallback(originalUrl, storageKey);
},
[],
);
/**
* Resolve video/audio URL.
*/
const resolveMediaUrl = useCallback(
async (storagePath: string | undefined): Promise<string> => {
if (!storagePath) return '';
const cache = preloadCacheRef.current;
// 1. Try in-memory blob URL lookup
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(storagePath);
if (readyUrl) return readyUrl;
}
// 2. Try persistent cache
if (cache?.getCachedBlobUrl) {
try {
const blobUrl = await cache.getCachedBlobUrl(storagePath);
if (blobUrl) {
createdBlobUrlsRef.current.add(blobUrl);
return blobUrl;
}
} catch {
// Fall through
}
}
// 3. Resolve URL
const originalUrl = resolveAssetPlaybackUrl(storagePath);
if (!originalUrl) return '';
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(originalUrl);
if (readyUrl) return readyUrl;
}
if (cache?.getCachedBlobUrl) {
try {
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
if (blobUrl) {
createdBlobUrlsRef.current.add(blobUrl);
return blobUrl;
}
} catch {
// Fall through
}
}
return originalUrl;
},
[],
);
// ============================================================================
// Actions
// ============================================================================
const navigateToPage = useCallback(
async (
targetPage: NavigablePage | null,
options: {
hasTransition?: boolean;
isBack?: boolean;
onSwitched?: () => void;
} = {},
) => {
const { hasTransition = false, isBack = false, onSwitched } = options;
if (!targetPage) {
dispatch({ type: 'RESET_TO_IDLE' });
onSwitched?.();
return;
}
// Start navigation atomically
dispatch({
type: 'START_NAVIGATION',
payload: {
hasTransition,
targetPageId: targetPage.id,
isBack,
},
});
// Resolve URLs (may be async)
const [imageUrl, videoUrl, audioUrl] = await Promise.all([
resolveToDisplayUrl(targetPage.background_image_url),
resolveMediaUrl(targetPage.background_video_url),
resolveMediaUrl(targetPage.background_audio_url),
]);
// Update current URLs
dispatch({
type: 'URLS_RESOLVED',
payload: { imageUrl, videoUrl, audioUrl },
});
// Notify caller
onSwitched?.();
// For blob URLs, decode image before marking ready
if (!hasTransition && (imageUrl.startsWith('blob:') || !imageUrl)) {
decodeImage(imageUrl).then(() => {
dispatch({ type: 'BACKGROUND_READY' });
});
}
},
[resolveToDisplayUrl, resolveMediaUrl],
);
const onBackgroundReady = useCallback(() => {
dispatch({ type: 'BACKGROUND_READY' });
}, []);
const onTransitionEnded = useCallback(() => {
dispatch({ type: 'TRANSITION_ENDED' });
}, []);
const resetBackgroundReady = useCallback(() => {
// This is called before navigation to reset state
// The actual reset happens in START_NAVIGATION
}, []);
const clearPreviousBackground = useCallback(() => {
dispatch({ type: 'CLEAR_PREVIOUS_BACKGROUND' });
}, []);
const setBackgroundDirectly = useCallback(
(imageUrl: string, videoUrl: string, audioUrl: string) => {
dispatch({
type: 'SET_BACKGROUND_DIRECTLY',
payload: { imageUrl, videoUrl, audioUrl },
});
},
[],
);
const resetToIdle = useCallback(() => {
dispatch({ type: 'RESET_TO_IDLE' });
}, []);
const onVideoBufferStateChange = useCallback((isBuffering: boolean) => {
dispatch({ type: 'SET_VIDEO_BUFFERING', payload: isBuffering });
}, []);
const markBackgroundReady = useCallback(() => {
dispatch({ type: 'BACKGROUND_READY' });
}, []);
const startTransition = useCallback(
(targetPageId: string | null, isBack = false) => {
dispatch({
type: 'START_TRANSITION',
payload: { targetPageId, isBack },
});
},
[],
);
// ============================================================================
// Effects
// ============================================================================
// Update lastKnownBgUrl for Safari black flash prevention
useEffect(() => {
if (state.currentImageUrl) {
dispatch({
type: 'UPDATE_LAST_KNOWN_BG',
payload: state.currentImageUrl,
});
}
}, [state.currentImageUrl]);
// Fade completion timer
useEffect(() => {
if (state.phase === 'fading_in') {
const duration = getCrossfadeDuration(
transitionSettingsRef.current?.durationMs,
);
const bufferMs = isSafari() ? 100 : 50;
fadeTimerRef.current = setTimeout(() => {
fadeTimerRef.current = null;
dispatch({ type: 'FADE_COMPLETED' });
}, duration + bufferMs);
}
return () => {
if (fadeTimerRef.current) {
clearTimeout(fadeTimerRef.current);
fadeTimerRef.current = null;
}
};
}, [state.phase]);
// ============================================================================
// Derived State
// ============================================================================
const derived = useMemo(
() => ({
isLoading: state.phase === 'preparing' || state.phase === 'loading_bg',
// Show spinner when:
// - Preparing navigation (resolving URLs)
// - Loading background (no video transition)
// - Transition video ended, waiting for background
// - Video transition active but buffering (video not playing yet)
showSpinner:
state.phase === 'preparing' ||
state.phase === 'loading_bg' ||
state.phase === 'transition_done' ||
(state.phase === 'transitioning' && state.isVideoBuffering),
showElements: state.phase === 'idle' || state.phase === 'fading_in',
showPreviousOverlay:
state.phase === 'loading_bg' || state.phase === 'transition_done',
// Keep transition video overlay visible through entire video transition flow:
// transitioning → transition_done → loading_bg → fading_in
// The overlay is only rendered when transitionPreview is set, so this won't
// affect direct navigation (no video transition).
showTransitionVideo:
state.phase === 'transitioning' ||
state.phase === 'transition_done' ||
state.phase === 'loading_bg' ||
state.phase === 'fading_in',
isFadingIn: state.phase === 'fading_in',
// Compatibility flags for existing components
isSwitching: state.phase !== 'idle' && state.phase !== 'fading_in',
isNewBgReady: state.phase === 'fading_in' || state.phase === 'idle',
isBackgroundReady: state.phase === 'idle' || state.phase === 'fading_in',
pendingTransitionComplete: state.phase === 'transition_done',
}),
[state.phase, state.isVideoBuffering],
);
// Transition style
const transitionStyle: React.CSSProperties = useMemo(
() =>
({
'--transition-duration': `${transitionSettings?.durationMs ?? 700}ms`,
'--transition-easing': transitionSettings?.easing ?? 'ease-in-out',
'--overlay-color': transitionSettings?.overlayColor ?? '#000000',
}) as React.CSSProperties,
[
transitionSettings?.durationMs,
transitionSettings?.easing,
transitionSettings?.overlayColor,
],
);
return {
// Current state
phase: state.phase,
state,
// Current page URLs
currentImageUrl: state.currentImageUrl,
currentVideoUrl: state.currentVideoUrl,
currentAudioUrl: state.currentAudioUrl,
// Previous page URLs
previousImageUrl: state.previousImageUrl,
previousVideoUrl: state.previousVideoUrl,
// Safari black flash prevention
lastKnownBgUrl: state.lastKnownBgUrl,
// Derived states
...derived,
// Video buffering
isVideoBuffering: state.isVideoBuffering,
// Transition style
transitionStyle,
// Actions
navigateToPage,
onBackgroundReady,
onTransitionEnded,
resetBackgroundReady,
clearPreviousBackground,
setBackgroundDirectly,
resetToIdle,
onVideoBufferStateChange,
markBackgroundReady,
startTransition,
};
}

View File

@ -1,553 +0,0 @@
/**
* usePageSwitch Hook
*
* Unified page navigation hook that eliminates white/black flashes during page transitions.
* Uses preloaded blob URLs when available and keeps previous background visible
* until new one is ready to paint.
*
* Features:
* - Blob URL resolution from preload cache (instant display)
* - Presigned URL fallback (retries with proxy on CORS failure)
* - Previous background overlay for smooth transitions
* - Ready state management for Image onLoad coordination
*/
import { useCallback, useRef, useState } from 'react';
import {
resolveAssetPlaybackUrl,
markPresignedUrlFailed,
isRelativeStoragePath,
isPresignedUrl,
buildProxyUrl,
} from '../lib/assetUrl';
import { logger } from '../lib/logger';
import {
scheduleAfterPaint,
scheduleAfterPaintSafari,
isSafari,
} from '../lib/browserUtils';
/**
* Minimal page interface for page switching
*/
export interface SwitchablePage {
id: string;
background_image_url?: string;
background_video_url?: string;
background_audio_url?: string;
// Background video playback settings
background_video_autoplay?: boolean;
background_video_loop?: boolean;
background_video_muted?: boolean;
background_video_start_time?: number | null;
background_video_end_time?: number | null;
}
/**
* Preload cache provider interface
*/
export interface PreloadCacheProvider {
/** Instant lookup - returns decoded blob URL ready to display (O(1)) */
getReadyBlobUrl?: (url: string) => string | null;
/** Fallback: async blob URL from cache (creates new blob URL) */
getCachedBlobUrl?: (url: string) => Promise<string | null>;
preloadedUrls?: Set<string>;
}
export interface UsePageSwitchOptions {
/** Preload cache provider for blob URL resolution */
preloadCache?: PreloadCacheProvider;
}
export interface UsePageSwitchResult {
/** Currently displayed background image URL */
currentBgImageUrl: string;
/** Currently displayed background video URL */
currentBgVideoUrl: string;
/** Currently displayed background audio URL */
currentBgAudioUrl: string;
/** Previous background image URL (for overlay) */
previousBgImageUrl: string;
/** Previous background video URL (for overlay during fade) */
previousBgVideoUrl: string;
/** Whether we're in the middle of a page switch */
isSwitching: boolean;
/** Whether the new background is ready to display */
isNewBgReady: boolean;
/**
* Switch to a new page with smooth transition.
* Resolves blob URLs from cache, shows previous background until new one is ready.
*/
switchToPage: (
targetPage: SwitchablePage | null,
onSwitched?: () => void,
) => Promise<void>;
/**
* Directly set backgrounds without transition overlay.
* Use for initial page load.
*/
setBackgroundsDirectly: (
imageUrl: string,
videoUrl: string,
audioUrl: string,
) => void;
/**
* Mark the new background as ready to display.
* Call this from Image onLoad callback.
*/
markBackgroundReady: () => void;
/**
* Clear the previous background overlay.
* Call after transition completes or when ready to show new background.
*/
clearPreviousBackground: () => void;
}
/**
* Decode an image from URL to ensure it's ready for display.
* Used for blob URLs that are already loaded but need decoding.
* Returns a promise that resolves when the image is decoded.
*
* Safari-specific: waits extra frame after decode to ensure pixels are painted.
*/
const decodeImage = (url: string): Promise<void> => {
return new Promise((resolve) => {
if (!url) {
resolve();
return;
}
const img = new window.Image();
const safariMode = isSafari();
const onReady = () => {
if (safariMode) {
scheduleAfterPaintSafari(() => resolve());
} else {
// For non-Safari, wait one paint frame after decode
scheduleAfterPaint(() => resolve());
}
};
img.onload = () => {
if (typeof img.decode === 'function') {
img.decode().then(onReady).catch(onReady); // Resolve even on decode error
} else {
onReady();
}
};
img.onerror = () => {
// Resolve even on error to not block navigation
onReady();
};
img.src = url;
});
};
/**
* Load and decode an image with presigned URL fallback.
* Returns the URL that successfully loaded.
*
* Safari-specific handling:
* Safari's img.decode() can resolve before pixels are actually ready for painting.
* For Safari, we add an extra frame wait after decode to ensure the image is
* truly ready to display, preventing black flash during page transitions.
*/
const loadImageWithFallback = (
url: string,
storageKey?: string,
): Promise<string> => {
return new Promise((resolve) => {
const img = new window.Image();
const safariMode = isSafari();
const onImageReady = (srcUrl: string) => {
if (safariMode) {
// Safari: wait an extra frame after decode to ensure pixels are ready
scheduleAfterPaintSafari(() => resolve(srcUrl));
} else {
resolve(srcUrl);
}
};
const tryLoad = (srcUrl: string, isRetry = false) => {
img.src = srcUrl;
img.onload = () => {
if (typeof img.decode === 'function') {
img
.decode()
.then(() => onImageReady(srcUrl))
.catch(() => onImageReady(srcUrl));
} else {
onImageReady(srcUrl);
}
};
img.onerror = () => {
// If presigned URL failed and we have storage key, retry with proxy
if (!isRetry && isPresignedUrl(srcUrl) && storageKey) {
logger.info('Image presigned URL failed, retrying with proxy', {
storageKey: storageKey.slice(-50),
});
markPresignedUrlFailed(storageKey);
const proxyUrl = buildProxyUrl(storageKey);
tryLoad(proxyUrl, true);
} else {
// Give up but still resolve to not block navigation
onImageReady(srcUrl);
}
};
};
tryLoad(url);
});
};
/**
* Hook for smooth page switching without white/black flashes.
*
* Strategy:
* 1. When switching pages, check if target background is in preload cache
* 2. If cached, use blob URL (instant local data)
* 3. If not cached, load with presigned URL fallback
* 4. Keep previous background visible until new one is ready
*
* @example
* const pageSwitch = usePageSwitch({
* preloadCache: {
* getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl,
* preloadedUrls: preloadOrchestrator?.preloadedUrls,
* },
* });
*
* // Switch to a page (with transition)
* await pageSwitch.switchToPage(targetPage, () => {
* setActivePageId(targetPage.id);
* });
*
* // In render, show previous background overlay while switching
* {pageSwitch.previousBgImageUrl && !pageSwitch.isNewBgReady && (
* <div style={{ backgroundImage: `url("${pageSwitch.previousBgImageUrl}")` }} />
* )}
*
* // On Image onLoad, mark background as ready
* <Image onLoad={() => pageSwitch.markBackgroundReady()} />
*/
export function usePageSwitch(
options: UsePageSwitchOptions = {},
): UsePageSwitchResult {
const { preloadCache } = options;
// Ref to track preload cache (avoids dependency issues with object identity)
const preloadCacheRef = useRef(preloadCache);
preloadCacheRef.current = preloadCache;
// Current backgrounds
const [currentBgImageUrl, setCurrentBgImageUrl] = useState('');
const [currentBgVideoUrl, setCurrentBgVideoUrl] = useState('');
const [currentBgAudioUrl, setCurrentBgAudioUrl] = useState('');
// Refs to track current URLs for use in callbacks (avoids dependency issues)
const currentBgImageUrlRef = useRef('');
const currentBgVideoUrlRef = useRef('');
const currentBgAudioUrlRef = useRef('');
currentBgImageUrlRef.current = currentBgImageUrl;
currentBgVideoUrlRef.current = currentBgVideoUrl;
currentBgAudioUrlRef.current = currentBgAudioUrl;
// Previous background for overlay
const [previousBgImageUrl, setPreviousBgImageUrl] = useState('');
const previousBgImageUrlRef = useRef('');
previousBgImageUrlRef.current = previousBgImageUrl;
const [previousBgVideoUrl, setPreviousBgVideoUrl] = useState('');
const previousBgVideoUrlRef = useRef('');
previousBgVideoUrlRef.current = previousBgVideoUrl;
// Transition state
const [isSwitching, setIsSwitching] = useState(false);
// Initialize as false to trigger fade-in animation on initial page load
const [isNewBgReady, setIsNewBgReady] = useState(false);
// Track blob URLs we created so we can revoke them
const createdBlobUrlsRef = useRef<Set<string>>(new Set());
/**
* Revoke blob URLs that we created to prevent memory leaks
*/
const revokeBlobUrl = useCallback((url: string) => {
if (url.startsWith('blob:') && createdBlobUrlsRef.current.has(url)) {
URL.revokeObjectURL(url);
createdBlobUrlsRef.current.delete(url);
}
}, []);
/**
* Resolve a storage path to a displayable URL.
* Priority: 1) ready blob URL by storage path, 2) cached blob URL by storage path,
* 3) ready blob URL by resolved URL, 4) cached blob URL, 5) presigned URL with fallback
*/
const resolveToDisplayUrl = useCallback(
async (storagePath: string | undefined): Promise<string> => {
if (!storagePath) return '';
const cache = preloadCacheRef.current;
// 1. Try in-memory storage path lookup first (instant, same session)
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(storagePath);
if (readyUrl) {
logger.info('Using ready blob URL (storage key)', {
storagePath: storagePath.slice(-50),
});
return readyUrl;
}
}
// 2. Try persistent cache by storage path (survives page refresh)
if (cache?.getCachedBlobUrl) {
try {
const blobUrl = await cache.getCachedBlobUrl(storagePath);
if (blobUrl) {
createdBlobUrlsRef.current.add(blobUrl);
logger.info('Using cached blob URL (storage key)', {
storagePath: storagePath.slice(-50),
});
return blobUrl;
}
} catch {
// Fall through to URL resolution
}
}
// 3. Resolve to playback URL and try lookup (fallback for resolved URLs)
const originalUrl = resolveAssetPlaybackUrl(storagePath);
if (!originalUrl) return '';
// Try instant blob URL lookup by resolved URL
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(originalUrl);
if (readyUrl) {
logger.info('Using ready blob URL', { url: originalUrl.slice(-50) });
return readyUrl;
}
}
// Fallback: try cached blob URL by resolved URL (check Cache API directly)
if (cache?.getCachedBlobUrl) {
try {
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
if (blobUrl) {
createdBlobUrlsRef.current.add(blobUrl);
logger.info('Using cached blob URL for background', {
originalUrl: originalUrl.slice(-50),
});
return blobUrl;
}
} catch {
// Fall through
}
}
// Load with presigned URL fallback (handles CORS failures)
const storageKey = isRelativeStoragePath(storagePath)
? storagePath
: undefined;
return loadImageWithFallback(originalUrl, storageKey);
},
[],
);
/**
* Resolve video/audio URL.
* Priority: 1) ready blob URL by storage path, 2) cached blob URL by storage path,
* 3) ready blob URL by resolved URL, 4) cached blob URL, 5) resolved URL
*/
const resolveMediaUrl = useCallback(
async (storagePath: string | undefined): Promise<string> => {
if (!storagePath) return '';
const cache = preloadCacheRef.current;
// 1. Try in-memory storage path lookup first (instant, same session)
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(storagePath);
if (readyUrl) {
return readyUrl;
}
}
// 2. Try persistent cache by storage path (survives page refresh)
if (cache?.getCachedBlobUrl) {
try {
const blobUrl = await cache.getCachedBlobUrl(storagePath);
if (blobUrl) {
createdBlobUrlsRef.current.add(blobUrl);
return blobUrl;
}
} catch {
// Fall through
}
}
// 3. Resolve URL and try lookup by resolved URL
const originalUrl = resolveAssetPlaybackUrl(storagePath);
if (!originalUrl) return '';
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(originalUrl);
if (readyUrl) {
return readyUrl;
}
}
// Try cached blob URL by resolved URL (check Cache API directly)
if (cache?.getCachedBlobUrl) {
try {
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
if (blobUrl) {
createdBlobUrlsRef.current.add(blobUrl);
return blobUrl;
}
} catch {
// Fall through
}
}
return originalUrl;
},
[],
);
/**
* Switch to a new page with smooth transition
*/
const switchToPage = useCallback(
async (targetPage: SwitchablePage | null, onSwitched?: () => void) => {
if (!targetPage) {
setCurrentBgImageUrl('');
setCurrentBgVideoUrl('');
setCurrentBgAudioUrl('');
setPreviousBgImageUrl('');
setPreviousBgVideoUrl('');
setIsSwitching(false);
setIsNewBgReady(true);
onSwitched?.();
return;
}
// CRITICAL: Resolve URLs BEFORE setting isSwitching
// This ensures the new background is ready when the crossfade animation starts.
// If we set isSwitching first, the animation begins with opacity:0 but the new
// content isn't ready yet, causing a shorter/faster perceived animation.
const [imageUrl, videoUrl, audioUrl] = await Promise.all([
resolveToDisplayUrl(targetPage.background_image_url),
resolveMediaUrl(targetPage.background_video_url),
resolveMediaUrl(targetPage.background_audio_url),
]);
// Save current backgrounds as previous for overlay (use refs to avoid dependencies)
if (currentBgImageUrlRef.current) {
setPreviousBgImageUrl(currentBgImageUrlRef.current);
}
if (currentBgVideoUrlRef.current) {
setPreviousBgVideoUrl(currentBgVideoUrlRef.current);
}
// Set new backgrounds BEFORE triggering animation
setCurrentBgImageUrl(imageUrl);
setCurrentBgVideoUrl(videoUrl);
setCurrentBgAudioUrl(audioUrl);
// NOW trigger the crossfade animation
// The new background is already set, so animation shows the actual crossfade
setIsSwitching(true);
setIsNewBgReady(false);
// Notify caller that backgrounds are set
onSwitched?.();
// For blob URLs, decode the image before marking ready
// This ensures the image is actually decoded and ready for display,
// matching the constructor behavior where images have a render cycle head start
if (imageUrl.startsWith('blob:') || !imageUrl) {
decodeImage(imageUrl).then(() => {
setIsNewBgReady(true);
});
}
// For remote images, wait for Image onLoad (caller should use markBackgroundReady)
},
[resolveToDisplayUrl, resolveMediaUrl],
);
/**
* Directly set backgrounds without transition overlay.
* Used for initial page load with fade-in animation.
*/
const setBackgroundsDirectly = useCallback(
(imageUrl: string, videoUrl: string, audioUrl: string) => {
// Revoke old blob URLs (use refs to avoid dependency)
revokeBlobUrl(currentBgImageUrlRef.current);
revokeBlobUrl(currentBgVideoUrlRef.current);
revokeBlobUrl(currentBgAudioUrlRef.current);
revokeBlobUrl(previousBgImageUrlRef.current);
setCurrentBgImageUrl(imageUrl);
setCurrentBgVideoUrl(videoUrl);
setCurrentBgAudioUrl(audioUrl);
setPreviousBgImageUrl('');
setIsSwitching(false);
// Trigger fade-in animation: set not-ready then ready after paint
// This ensures the CSS animation triggers on initial page load
setIsNewBgReady(false);
scheduleAfterPaint(() => {
setIsNewBgReady(true);
});
},
[revokeBlobUrl],
);
/**
* Mark background as ready (call from Image onLoad)
*/
const markBackgroundReady = useCallback(() => {
setIsNewBgReady(true);
}, []);
/**
* Clear the previous background overlay (both image and video)
*/
const clearPreviousBackground = useCallback(() => {
const prevImageUrl = previousBgImageUrlRef.current;
const prevVideoUrl = previousBgVideoUrlRef.current;
setPreviousBgImageUrl('');
setPreviousBgVideoUrl('');
setIsSwitching(false);
// Revoke the previous blob URLs after clearing
if (prevImageUrl) {
revokeBlobUrl(prevImageUrl);
}
if (prevVideoUrl) {
revokeBlobUrl(prevVideoUrl);
}
}, [revokeBlobUrl]);
return {
currentBgImageUrl,
currentBgVideoUrl,
currentBgAudioUrl,
previousBgImageUrl,
previousBgVideoUrl,
isSwitching,
isNewBgReady,
switchToPage,
setBackgroundsDirectly,
markBackgroundReady,
clearPreviousBackground,
};
}

View File

@ -1,13 +1,13 @@
/** /**
* usePreloadOrchestrator Hook * usePreloadOrchestrator Hook
* *
* Main coordinator for online mode asset preloading. * Coordinates asset preloading based on navigation.
* Manages the priority queue and orchestrates downloads based on navigation. * Preloads current page assets and outgoing transition videos.
*/ */
import { useEffect, useRef, useCallback, useState, useMemo } from 'react'; import { useEffect, useRef, useCallback, useState } from 'react';
import { useNeighborGraph } from './useNeighborGraph';
import { useNetworkAware } from './useNetworkAware'; import { useNetworkAware } from './useNetworkAware';
import { extractElementAssets } from '../lib/assetCache';
import { downloadEventBus } from '../lib/offline/DownloadEventBus'; import { downloadEventBus } from '../lib/offline/DownloadEventBus';
import { downloadManager } from '../lib/offline/DownloadManager'; import { downloadManager } from '../lib/offline/DownloadManager';
import { StorageManager } from '../lib/offline/StorageManager'; import { StorageManager } from '../lib/offline/StorageManager';
@ -36,44 +36,44 @@ interface UsePreloadOrchestratorOptions {
currentPageId: string | null; currentPageId: string | null;
pageHistory?: string[]; pageHistory?: string[];
enabled?: boolean; enabled?: boolean;
maxNeighborDepth?: number;
} }
interface PreloadQueueItem { interface PreloadQueueItem {
id: string; id: string;
url: string; url: string;
storageKey?: string; // Original storage key for presigned URL cache invalidation storageKey?: string;
priority: number; priority: number;
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other'; assetType: 'image' | 'video' | 'audio' | 'transition' | 'other';
pageId: string; pageId: string;
} }
export type PreloadPhase =
| 'idle'
| 'phase1_current_page'
| 'phase2_transitions'
| 'complete';
interface UsePreloadOrchestratorResult { interface UsePreloadOrchestratorResult {
isPreloading: boolean; isPreloading: boolean;
preloadedUrls: Set<string>; preloadedUrls: Set<string>;
queueLength: number; queueLength: number;
/** Version counter that increments when blob URLs become ready (triggers re-renders) */
readyUrlsVersion: number; readyUrlsVersion: number;
preloadAsset: (url: string, priority?: number) => void; preloadAsset: (url: string, priority?: number) => void;
clearQueue: () => void; clearQueue: () => void;
getCachedBlobUrl: (url: string) => Promise<string | null>; getCachedBlobUrl: (url: string) => Promise<string | null>;
isUrlPreloaded: (url: string) => Promise<boolean>; isUrlPreloaded: (url: string) => Promise<boolean>;
/** Instant lookup - returns decoded blob URL or null */
getReadyBlobUrl: (url: string) => string | null; getReadyBlobUrl: (url: string) => string | null;
/** Whether all neighbor page backgrounds are ready for instant navigation */ getReadyBlob: (url: string) => Blob | null;
areNeighborBackgroundsReady: boolean; currentPhase: PreloadPhase;
phaseProgress: number;
isCurrentPageReady: boolean;
areTransitionsReady: boolean;
} }
/**
* Generate a unique ID for preload jobs
*/
const generateJobId = (): string => { const generateJobId = (): string => {
return `preload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; return `preload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}; };
/**
* Map asset type string to AssetType enum expected by DownloadManager
*/
const mapAssetType = ( const mapAssetType = (
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other', assetType: 'image' | 'video' | 'audio' | 'transition' | 'other',
): 'image' | 'video' | 'audio' | 'transition' | 'other' => { ): 'image' | 'video' | 'audio' | 'transition' | 'other' => {
@ -83,79 +83,28 @@ const mapAssetType = (
export function usePreloadOrchestrator( export function usePreloadOrchestrator(
options: UsePreloadOrchestratorOptions, options: UsePreloadOrchestratorOptions,
): UsePreloadOrchestratorResult { ): UsePreloadOrchestratorResult {
const { const { pages, pageLinks, elements, currentPageId, enabled = true } = options;
pages,
pageLinks,
elements,
currentPageId,
enabled = true,
maxNeighborDepth = 1, // Only preload immediate neighbors by default
} = options;
const [isPreloading, setIsPreloading] = useState(false); const [isPreloading, setIsPreloading] = useState(false);
const [preloadedUrls] = useState(() => new Set<string>()); const [preloadedUrls] = useState(() => new Set<string>());
const [queueLength, setQueueLength] = useState(0); const [queueLength, setQueueLength] = useState(0);
// Version counter to trigger re-renders when blob URLs become ready
const [readyUrlsVersion, setReadyUrlsVersion] = useState(0); const [readyUrlsVersion, setReadyUrlsVersion] = useState(0);
const [currentPhase, setCurrentPhase] = useState<PreloadPhase>('idle');
const [phaseProgress, setPhaseProgress] = useState(0);
const queueRef = useRef<PreloadQueueItem[]>([]); const queueRef = useRef<PreloadQueueItem[]>([]);
const isProcessingRef = useRef(false); const isProcessingRef = useRef(false);
const lastPreloadedPageRef = useRef<string | null>(null); const lastPreloadedPageRef = useRef<string | null>(null);
const lastPreloadedLinksCountRef = useRef<number>(0); const lastPreloadedLinksCountRef = useRef<number>(0);
// Use neighbor graph for determining what to preload
const neighborGraph = useNeighborGraph({
pages,
pageLinks,
elements,
maxDepth: maxNeighborDepth,
});
// Use network info for adaptive preloading
const { networkInfo } = useNetworkAware(); const { networkInfo } = useNetworkAware();
// Compute whether all neighbor page backgrounds are ready for instant navigation
// Uses readyUrlsVersion to trigger re-computation when blob URLs become ready
const areNeighborBackgroundsReady = useMemo(() => {
if (!currentPageId || !enabled) return true; // Assume ready if disabled
// Use existing neighborGraph infrastructure
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
if (neighbors.length === 0) return true; // No neighbors = ready
// Check if ALL neighbor background images have READY blob URLs
// IMPORTANT: Use downloadManager.getReadyBlobUrl() NOT preloadedUrls.has()
// preloadedUrls contains URLs that are QUEUED, not URLs that are READY
// We need to check if the blob URL is actually available for instant display
return neighbors.every(({ pageId }) => {
const page = pages.find((p) => p.id === pageId);
if (!page) return true; // Page not found = skip
// If page has background image, check if blob URL is actually ready
if (page.background_image_url) {
const imageKey = extractStoragePath(page.background_image_url);
// Check if blob URL is ready (not just queued)
if (!downloadManager.getReadyBlobUrl(imageKey)) return false;
}
// If page has only video background (no image), it can stream - consider ready
// This allows navigation to video-only pages without blocking
return true;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPageId, enabled, neighborGraph, pages, readyUrlsVersion]);
// Subscribe to blob URL ready events from DownloadManager
useEffect(() => { useEffect(() => {
const unsubscribe = downloadEventBus.on( const unsubscribe = downloadEventBus.on(
OFFLINE_CONFIG.events.blobUrlReady as Parameters< OFFLINE_CONFIG.events.blobUrlReady as Parameters<
typeof downloadEventBus.on typeof downloadEventBus.on
>[0], >[0],
(data: BlobUrlReadyEvent) => { (data: BlobUrlReadyEvent) => {
logger.info('[PRELOAD] Blob URL ready from DownloadManager', {
storageKey: data.storageKey.slice(-50),
});
preloadedUrls.add(data.storageKey); preloadedUrls.add(data.storageKey);
setReadyUrlsVersion((v) => v + 1); setReadyUrlsVersion((v) => v + 1);
}, },
@ -163,14 +112,12 @@ export function usePreloadOrchestrator(
return unsubscribe; return unsubscribe;
}, [preloadedUrls]); }, [preloadedUrls]);
// Cleanup blob URLs on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
downloadManager.clearBlobUrls(); downloadManager.clearBlobUrls();
}; };
}, []); }, []);
// Process the queue using DownloadManager
const processQueue = useCallback(async () => { const processQueue = useCallback(async () => {
if (isProcessingRef.current) return; if (isProcessingRef.current) return;
if (!networkInfo.isOnline) return; if (!networkInfo.isOnline) return;
@ -182,42 +129,29 @@ export function usePreloadOrchestrator(
isProcessingRef.current = true; isProcessingRef.current = true;
setIsPreloading(true); setIsPreloading(true);
// Process all items in queue
while (queueRef.current.length > 0) { while (queueRef.current.length > 0) {
const item = queueRef.current.shift(); const item = queueRef.current.shift();
if (!item) break; if (!item) break;
setQueueLength(queueRef.current.length); setQueueLength(queueRef.current.length);
// Get canonical storage key
const storageKey = item.storageKey || extractStoragePath(item.url); const storageKey = item.storageKey || extractStoragePath(item.url);
// Skip if already preloaded
if (preloadedUrls.has(storageKey)) { if (preloadedUrls.has(storageKey)) {
continue; continue;
} }
logger.info('[PRELOAD] Queuing with DownloadManager', {
url: item.url.slice(-50),
storageKey: storageKey.slice(-50),
assetType: item.assetType,
priority: item.priority,
});
// Use DownloadManager for unified download and blob URL creation
// DownloadManager automatically handles presigned URL → proxy fallback
downloadManager downloadManager
.addJob({ .addJob({
assetId: item.id, assetId: item.id,
projectId: '', // Not needed for online preload projectId: '',
url: item.url, url: item.url,
filename: item.url.split('/').pop() || 'asset', filename: item.url.split('/').pop() || 'asset',
variantType: 'original', variantType: 'original',
assetType: mapAssetType(item.assetType), assetType: mapAssetType(item.assetType),
priority: item.priority, priority: item.priority,
storageKey, storageKey,
createBlobUrl: true, // Create blob URL for instant display persist: false,
persist: false, // Don't persist for online preload (in-memory only)
}) })
.then(() => { .then(() => {
if (isPresignedUrl(item.url)) { if (isPresignedUrl(item.url)) {
@ -238,12 +172,10 @@ export function usePreloadOrchestrator(
isProcessingRef.current = false; isProcessingRef.current = false;
}, [networkInfo.isOnline, preloadedUrls]); }, [networkInfo.isOnline, preloadedUrls]);
// Add item to queue with priority sorting
const addToQueue = useCallback( const addToQueue = useCallback(
(item: PreloadQueueItem) => { (item: PreloadQueueItem) => {
const storageKey = item.storageKey || extractStoragePath(item.url); const storageKey = item.storageKey || extractStoragePath(item.url);
// Skip if already in queue or preloaded
if ( if (
preloadedUrls.has(storageKey) || preloadedUrls.has(storageKey) ||
queueRef.current.some( queueRef.current.some(
@ -253,15 +185,6 @@ export function usePreloadOrchestrator(
return; return;
} }
logger.info('[PRELOAD] Adding to queue', {
url: item.url.slice(-60),
storageKey: storageKey.slice(-50),
assetType: item.assetType,
priority: item.priority,
queueLength: queueRef.current.length + 1,
});
// Insert in priority order (higher priority first)
const insertIndex = queueRef.current.findIndex( const insertIndex = queueRef.current.findIndex(
(q) => q.priority < item.priority, (q) => q.priority < item.priority,
); );
@ -278,7 +201,6 @@ export function usePreloadOrchestrator(
[preloadedUrls, processQueue], [preloadedUrls, processQueue],
); );
// Manual preload function
const preloadAsset = useCallback( const preloadAsset = useCallback(
(url: string, priority = 100) => { (url: string, priority = 100) => {
addToQueue({ addToQueue({
@ -292,14 +214,11 @@ export function usePreloadOrchestrator(
[addToQueue, currentPageId], [addToQueue, currentPageId],
); );
// Clear queue
const clearQueue = useCallback(() => { const clearQueue = useCallback(() => {
queueRef.current = []; queueRef.current = [];
setQueueLength(0); setQueueLength(0);
}, []); }, []);
// Get a cached asset as a blob URL (for video playback)
// StorageManager.getAsset checks both IndexedDB (large files ≥ 5MB) and Cache API (small files)
const getCachedBlobUrl = useCallback( const getCachedBlobUrl = useCallback(
async (url: string): Promise<string | null> => { async (url: string): Promise<string | null> => {
try { try {
@ -315,27 +234,24 @@ export function usePreloadOrchestrator(
[], [],
); );
// Check if URL is preloaded (in cache)
const isUrlPreloaded = useCallback( const isUrlPreloaded = useCallback(
async (url: string): Promise<boolean> => { async (url: string): Promise<boolean> => {
const storageKey = extractStoragePath(url); const storageKey = extractStoragePath(url);
// First check in-memory set
if (preloadedUrls.has(storageKey)) return true; if (preloadedUrls.has(storageKey)) return true;
// Then check via StorageManager
return StorageManager.hasAsset(storageKey); return StorageManager.hasAsset(storageKey);
}, },
[preloadedUrls], [preloadedUrls],
); );
// Instant lookup - returns decoded blob URL or null (O(1) Map lookup)
// Uses DownloadManager's unified blob URL cache
const getReadyBlobUrl = useCallback((url: string): string | null => { const getReadyBlobUrl = useCallback((url: string): string | null => {
return downloadManager.getReadyBlobUrl(url); return downloadManager.getReadyBlobUrl(url);
}, []); }, []);
const getReadyBlob = useCallback((url: string): Blob | null => {
return downloadManager.getReadyBlob(url);
}, []);
// Initialize ready blob URLs from cache for current page's assets // Initialize ready blob URLs from cache for current page's assets
// This ensures getReadyBlobUrl works on the first render for both backgrounds and element icons
useEffect(() => { useEffect(() => {
if (!currentPageId) return; if (!currentPageId) return;
@ -343,14 +259,12 @@ export function usePreloadOrchestrator(
if (!currentPage) return; if (!currentPage) return;
const initializeFromCache = async () => { const initializeFromCache = async () => {
// Collect background URLs
const bgUrls = [ const bgUrls = [
currentPage.background_image_url, currentPage.background_image_url,
currentPage.background_video_url, currentPage.background_video_url,
currentPage.background_audio_url, currentPage.background_audio_url,
].filter(Boolean) as string[]; ].filter(Boolean) as string[];
// Collect element asset URLs (icons, images, etc.) from current page
const currentPageElements = elements.filter( const currentPageElements = elements.filter(
(el) => el.pageId === currentPageId, (el) => el.pageId === currentPageId,
); );
@ -363,7 +277,6 @@ export function usePreloadOrchestrator(
? JSON.parse(element.content_json) ? JSON.parse(element.content_json)
: element.content_json; : element.content_json;
// Extract URLs from known asset fields
const urlFields = PRELOAD_CONFIG.assetFields.all as readonly string[]; const urlFields = PRELOAD_CONFIG.assetFields.all as readonly string[];
const checkObject = (obj: Record<string, unknown>) => { const checkObject = (obj: Record<string, unknown>) => {
if (!obj || typeof obj !== 'object') return; if (!obj || typeof obj !== 'object') return;
@ -385,20 +298,16 @@ export function usePreloadOrchestrator(
} }
}); });
// Initialize all URLs from cache via DownloadManager
const allUrls = [...bgUrls, ...elementAssetUrls]; const allUrls = [...bgUrls, ...elementAssetUrls];
for (const storagePath of allUrls) { for (const storagePath of allUrls) {
const storageKey = extractStoragePath(storagePath); const storageKey = extractStoragePath(storagePath);
// Skip if already ready
if (downloadManager.getReadyBlobUrl(storageKey)) continue; if (downloadManager.getReadyBlobUrl(storageKey)) continue;
// Check if cached and create blob URL if so
const fullUrl = resolveAssetPlaybackUrl(storagePath); const fullUrl = resolveAssetPlaybackUrl(storagePath);
const hasAsset = await StorageManager.hasAsset(storageKey); const hasAsset = await StorageManager.hasAsset(storageKey);
if (hasAsset) { if (hasAsset) {
// Use DownloadManager.addJob with createBlobUrl to create the blob URL
await downloadManager.addJob({ await downloadManager.addJob({
assetId: `init-${storageKey}`, assetId: `init-${storageKey}`,
projectId: '', projectId: '',
@ -407,7 +316,6 @@ export function usePreloadOrchestrator(
variantType: 'original', variantType: 'original',
assetType: 'other', assetType: 'other',
storageKey, storageKey,
createBlobUrl: true,
persist: false, persist: false,
}); });
} }
@ -417,14 +325,12 @@ export function usePreloadOrchestrator(
initializeFromCache(); initializeFromCache();
}, [currentPageId, pages, elements]); }, [currentPageId, pages, elements]);
// React to page changes - preload neighbors // React to page changes - preload current page assets and transitions
useEffect(() => { useEffect(() => {
if (!enabled || !currentPageId || !networkInfo.isOnline) { if (!enabled || !currentPageId || !networkInfo.isOnline) {
return; return;
} }
// Skip if we already preloaded for this page with the same data
// Re-preload if pageLinks count changed (data just loaded)
const currentLinksCount = pageLinks.length; const currentLinksCount = pageLinks.length;
const samePageAndData = const samePageAndData =
lastPreloadedPageRef.current === currentPageId && lastPreloadedPageRef.current === currentPageId &&
@ -436,26 +342,17 @@ export function usePreloadOrchestrator(
lastPreloadedPageRef.current = currentPageId; lastPreloadedPageRef.current = currentPageId;
lastPreloadedLinksCountRef.current = currentLinksCount; lastPreloadedLinksCountRef.current = currentLinksCount;
logger.info('[PRELOAD] Starting preload for page', {
currentPageId,
maxNeighborDepth,
});
// Get prioritized assets based on current page
const assets = neighborGraph.getPrioritizedAssets(
currentPageId,
maxNeighborDepth,
);
logger.info('[PRELOAD] Found assets from neighbor graph', {
assetCount: assets.length,
assets: assets.map((a) => ({ type: a.assetType, url: a.url.slice(-50) })),
});
// Collect all raw storage paths that need presigning
const storagePaths: string[] = [];
const currentPage = pages.find((p) => p.id === currentPageId); const currentPage = pages.find((p) => p.id === currentPageId);
// Extract current page element assets directly
const currentPageElements = elements.filter(
(el) => el.pageId === currentPageId,
);
const elementAssets = extractElementAssets(currentPageElements, currentPageId);
// Collect storage paths for presigned URL batch request
const storagePaths: string[] = [];
if ( if (
currentPage?.background_image_url && currentPage?.background_image_url &&
isRelativeStoragePath(currentPage.background_image_url) isRelativeStoragePath(currentPage.background_image_url)
@ -475,92 +372,49 @@ export function usePreloadOrchestrator(
storagePaths.push(currentPage.background_audio_url); storagePaths.push(currentPage.background_audio_url);
} }
assets.forEach((asset) => { elementAssets.forEach((asset) => {
if (isRelativeStoragePath(asset.url)) { if (isRelativeStoragePath(asset.storageKey)) {
storagePaths.push(asset.url); storagePaths.push(asset.storageKey);
} }
}); });
// Always collect neighbor background URLs for presigning // Add outgoing transition video URLs (forward and reverse)
// This ensures instant navigation to neighbor pages // Reverse videos are preloaded here so they're cached when user navigates and clicks back
const neighbors = neighborGraph.getNeighbors(currentPageId, 1); const outgoingTransitions = pageLinks.filter(
neighbors.forEach(({ pageId }) => { (link) =>
const page = pages.find((p) => p.id === pageId); link.from_pageId === currentPageId &&
if ( (link.transition?.video_url || link.transition?.reverse_video_url),
page?.background_image_url && );
isRelativeStoragePath(page.background_image_url)
) { outgoingTransitions.forEach((link) => {
storagePaths.push(page.background_image_url); const forwardVideoUrl = link.transition?.video_url;
const reverseVideoUrl = link.transition?.reverse_video_url;
if (forwardVideoUrl && isRelativeStoragePath(forwardVideoUrl)) {
storagePaths.push(forwardVideoUrl);
} }
// Always collect neighbor video URLs for smooth transitions if (reverseVideoUrl && isRelativeStoragePath(reverseVideoUrl)) {
if ( storagePaths.push(reverseVideoUrl);
page?.background_video_url &&
isRelativeStoragePath(page.background_video_url)
) {
storagePaths.push(page.background_video_url);
}
// Also collect neighbor audio URLs
if (
page?.background_audio_url &&
isRelativeStoragePath(page.background_audio_url)
) {
storagePaths.push(page.background_audio_url);
} }
}); });
// Batch fetch presigned URLs, then add to queue
// Helper to resolve URL - prefer presigned if available, else fallback to proxy
const resolveUrl = ( const resolveUrl = (
storageKey: string, storageKey: string,
presignedUrls: Record<string, string>, presignedUrls: Record<string, string>,
): string => { ): string => {
// Use presigned URL if available (will be tested on actual download)
if (presignedUrls[storageKey]) { if (presignedUrls[storageKey]) {
return presignedUrls[storageKey]; return presignedUrls[storageKey];
} }
// Fallback to resolveAssetPlaybackUrl (will use proxy)
return resolveAssetPlaybackUrl(storageKey); return resolveAssetPlaybackUrl(storageKey);
}; };
// Two-phase preloading: current page first, then neighbors
const addAssetsToQueue = async ( const addAssetsToQueue = async (
presignedUrls: Record<string, string> = {}, presignedUrls: Record<string, string> = {},
) => { ) => {
// Helper to determine max bytes for partial preload (online mode only)
// IMPORTANT: Only applies to NEIGHBOR pages, not the current page
// Transitions always use partial preload (regardless of page)
const getMaxBytesForAsset = (
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other',
isNeighborPage: boolean,
): number | undefined => {
if (!PRELOAD_CONFIG.partialPreload.enabled) return undefined;
// Transitions always use partial preload - they need just enough to start quickly
if (assetType === 'transition') {
return PRELOAD_CONFIG.partialPreload.transitionMaxBytes;
}
// Current page assets should be fully downloaded for best UX
if (!isNeighborPage) return undefined;
// Neighbor page media uses partial preload
switch (assetType) {
case 'video':
return PRELOAD_CONFIG.partialPreload.videoMaxBytes;
case 'audio':
return PRELOAD_CONFIG.partialPreload.audioMaxBytes;
default:
return undefined; // Images need full download for display
}
};
// Helper to create download job
const createDownloadJob = ( const createDownloadJob = (
id: string, id: string,
storageKey: string, storageKey: string,
priority: number, priority: number,
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other', assetType: 'image' | 'video' | 'audio' | 'transition' | 'other',
pageId: string,
): Promise<void> | null => { ): Promise<void> | null => {
const resolvedUrl = resolveUrl(storageKey, presignedUrls); const resolvedUrl = resolveUrl(storageKey, presignedUrls);
if (!resolvedUrl) return null; if (!resolvedUrl) return null;
@ -569,19 +423,26 @@ export function usePreloadOrchestrator(
? storageKey ? storageKey
: extractStoragePath(resolvedUrl); : extractStoragePath(resolvedUrl);
// Skip if already preloaded // Check if already downloaded (blob exists) or download in progress
if (preloadedUrls.has(normalizedKey)) return null; if (preloadedUrls.has(normalizedKey)) {
// Verify the blob actually exists - if not, allow re-download
const existingBlob = downloadManager.getReadyBlob(normalizedKey);
if (existingBlob) {
return null; // Already cached, skip
}
// Key was in Set but blob doesn't exist - remove and re-download
preloadedUrls.delete(normalizedKey);
}
// Mark as in-progress to prevent duplicate downloads
preloadedUrls.add(normalizedKey); preloadedUrls.add(normalizedKey);
// Determine if partial preload applies (neighbor pages only, media files only) const enableStreaming =
const isNeighborPage = pageId !== currentPageId; PRELOAD_CONFIG.streaming.enabled &&
const maxBytes = getMaxBytesForAsset(assetType, isNeighborPage); (assetType === 'video' ||
// Create blob URL for images (instant navigation) and full downloads assetType === 'audio' ||
// Partial downloads (video/audio/transition) use presigned URL directly for playback assetType === 'transition');
const createBlobUrl = assetType === 'image' || maxBytes === undefined;
// DownloadManager automatically handles presigned URL → proxy fallback
return downloadManager return downloadManager
.addJob({ .addJob({
assetId: id, assetId: id,
@ -592,9 +453,13 @@ export function usePreloadOrchestrator(
assetType: mapAssetType(assetType), assetType: mapAssetType(assetType),
priority, priority,
storageKey: normalizedKey, storageKey: normalizedKey,
createBlobUrl,
persist: false, persist: false,
maxBytes, streamingMode: enableStreaming
? {
enabled: true,
minBufferBytes: PRELOAD_CONFIG.streaming.minBufferBytes,
}
: undefined,
}) })
.then(() => { .then(() => {
if (isPresignedUrl(resolvedUrl)) { if (isPresignedUrl(resolvedUrl)) {
@ -602,6 +467,8 @@ export function usePreloadOrchestrator(
} }
}) })
.catch((err) => { .catch((err) => {
// Download failed - remove from Set so it can be retried
preloadedUrls.delete(normalizedKey);
logger.error('[PRELOAD] Download failed', { logger.error('[PRELOAD] Download failed', {
url: resolvedUrl.slice(-50), url: resolvedUrl.slice(-50),
error: err?.message, error: err?.message,
@ -609,173 +476,164 @@ export function usePreloadOrchestrator(
}); });
}; };
// ============================================ // Phase 1: Current Page Assets (blocking for images only)
// PHASE 1: Load current page IMAGE backgrounds only and WAIT setCurrentPhase('phase1_current_page');
// Video/audio backgrounds stream on their own - don't block on them setPhaseProgress(0);
// ============================================
logger.info('[PRELOAD] Phase 1: Loading current page backgrounds');
const currentPageImageJobs: Promise<void>[] = []; const phase1BlockingJobs: Promise<void>[] = [];
let phase1Total = 0;
let phase1Completed = 0;
// Current page IMAGE background - WAIT for this (essential for visual)
if (currentPage?.background_image_url) { if (currentPage?.background_image_url) {
phase1Total++;
const job = createDownloadJob( const job = createDownloadJob(
`bg-img-${currentPageId}`, `bg-img-${currentPageId}`,
currentPage.background_image_url, currentPage.background_image_url,
PRELOAD_CONFIG.priority.currentPage + 200, PRELOAD_CONFIG.priority.currentPage + 200,
'image', 'image',
currentPageId,
); );
if (job) currentPageImageJobs.push(job); if (job) {
phase1BlockingJobs.push(
job.then(() => {
phase1Completed++;
setPhaseProgress(
phase1Total > 0 ? (phase1Completed / phase1Total) * 100 : 100,
);
}),
);
}
} }
// Current page VIDEO/AUDIO backgrounds - DON'T wait (they can stream) // Current page element images (blocking)
// These are started but not awaited - video player buffers on its own const currentPageImageAssets = elementAssets.filter(
(asset) => asset.assetType === 'image',
);
currentPageImageAssets.forEach((asset) => {
phase1Total++;
const job = createDownloadJob(
`elem-img-${asset.storageKey}`,
asset.storageKey,
PRELOAD_CONFIG.priority.currentPage + PRELOAD_CONFIG.priority.assetType.image,
'image',
);
if (job) {
phase1BlockingJobs.push(
job.then(() => {
phase1Completed++;
setPhaseProgress(
phase1Total > 0 ? (phase1Completed / phase1Total) * 100 : 100,
);
}),
);
}
});
// Non-blocking: videos and audio start downloading but don't wait
if (currentPage?.background_video_url) { if (currentPage?.background_video_url) {
createDownloadJob( createDownloadJob(
`bg-vid-${currentPageId}`, `bg-vid-${currentPageId}`,
currentPage.background_video_url, currentPage.background_video_url,
PRELOAD_CONFIG.priority.currentPage + 150, PRELOAD_CONFIG.priority.currentPage + 150,
'video', 'video',
currentPageId,
); );
// Not pushed to awaited jobs - video streams on its own
} }
if (currentPage?.background_audio_url) { if (currentPage?.background_audio_url) {
createDownloadJob( createDownloadJob(
`bg-aud-${currentPageId}`, `bg-aud-${currentPageId}`,
currentPage.background_audio_url, currentPage.background_audio_url,
PRELOAD_CONFIG.priority.currentPage + 100, PRELOAD_CONFIG.priority.currentPage + 100,
'audio', 'audio',
currentPageId,
); );
// Not pushed to awaited jobs - audio streams on its own
} }
// Wait ONLY for IMAGE backgrounds (they're small and essential) if (phase1BlockingJobs.length > 0) {
// Video/audio can stream - don't block the page await Promise.all(phase1BlockingJobs);
const phase1Start = Date.now();
if (currentPageImageJobs.length > 0) {
logger.info('[PRELOAD] Waiting for current page image backgrounds', {
count: currentPageImageJobs.length,
});
await Promise.all(currentPageImageJobs);
logger.info('[PRELOAD] Phase 1 complete', {
elapsed: `${Date.now() - phase1Start}ms`,
});
} else {
logger.info('[PRELOAD] Phase 1 complete (no image backgrounds)');
} }
// ============================================ // Phase 2: Outgoing Transition Videos (preload for instant playback)
// PHASE 2: Preload everything else (don't wait) setCurrentPhase('phase2_transitions');
// - Current page element assets (full downloads) setPhaseProgress(0);
// - Neighbor page backgrounds (partial preload for video/audio)
// - Neighbor page element assets (partial preload for video/audio)
// - Transition videos from page links (partial preload - 3MB)
// ============================================
logger.info('[PRELOAD] Phase 2: Preloading neighbors and transitions');
// Current page element assets (moved from Phase 1 for faster startup) const phase2Jobs: Promise<void>[] = [];
const currentPageAssets = assets.filter( let phase2Total = 0;
(asset) => asset.pageId === currentPageId, let phase2Completed = 0;
);
currentPageAssets.forEach((asset) => {
createDownloadJob(
generateJobId(),
asset.url,
asset.priority,
asset.assetType,
asset.pageId,
);
});
// Neighbor page element assets // Preload outgoing transition videos (forward + reverse)
const neighborAssets = assets.filter( outgoingTransitions.forEach((link) => {
(asset) => asset.pageId !== currentPageId, const forwardVideoUrl = link.transition?.video_url;
); const reverseVideoUrl = link.transition?.reverse_video_url;
neighborAssets.forEach((asset) => {
createDownloadJob(
generateJobId(),
asset.url,
asset.priority,
asset.assetType,
asset.pageId,
);
});
// Neighbor background assets // Preload forward transition video
const neighbors = neighborGraph.getNeighbors(currentPageId, 1); if (forwardVideoUrl) {
neighbors.forEach(({ pageId }) => { phase2Total++;
const page = pages.find((p) => p.id === pageId); const job = createDownloadJob(
if (page?.background_image_url) { `trans-fwd-${link.from_pageId}-${link.to_pageId}`,
createDownloadJob( forwardVideoUrl,
`bg-img-${pageId}`, PRELOAD_CONFIG.priority.currentPage +
page.background_image_url, PRELOAD_CONFIG.priority.assetType.transition,
PRELOAD_CONFIG.priority.neighborBase + 100, 'transition',
'image',
pageId,
); );
if (job) {
phase2Jobs.push(
job.then(() => {
phase2Completed++;
setPhaseProgress(
phase2Total > 0 ? (phase2Completed / phase2Total) * 100 : 100,
);
}),
);
}
} }
if (page?.background_video_url) {
createDownloadJob( // Preload reverse transition video (for potential back navigation from target)
`bg-vid-${pageId}`, if (reverseVideoUrl) {
page.background_video_url, phase2Total++;
PRELOAD_CONFIG.priority.neighborBase + 50, const job = createDownloadJob(
'video', `trans-rev-${link.from_pageId}-${link.to_pageId}`,
pageId, reverseVideoUrl,
); PRELOAD_CONFIG.priority.currentPage +
} PRELOAD_CONFIG.priority.assetType.transition - 10,
if (page?.background_audio_url) { 'transition',
createDownloadJob(
`bg-aud-${pageId}`,
page.background_audio_url,
PRELOAD_CONFIG.priority.neighborBase + 30,
'audio',
pageId,
); );
if (job) {
phase2Jobs.push(
job.then(() => {
phase2Completed++;
setPhaseProgress(
phase2Total > 0 ? (phase2Completed / phase2Total) * 100 : 100,
);
}),
);
}
} }
}); });
logger.info('[PRELOAD] Phase 2: Neighbor assets queued'); if (phase2Jobs.length > 0) {
await Promise.all(phase2Jobs);
}
setCurrentPhase('complete');
setPhaseProgress(100);
}; };
// If there are storage paths to presign, fetch them first
if (storagePaths.length > 0) { if (storagePaths.length > 0) {
logger.info('[PRELOAD] Fetching presigned URLs', {
count: storagePaths.length,
});
queuePresignedUrls(storagePaths) queuePresignedUrls(storagePaths)
.then(async () => { .then(async () => {
logger.info('[PRELOAD] Presigned URLs fetched, adding to queue');
// Note: Don't call markPresignedUrlsVerified() here - it's called after
// first successful download to verify CORS is configured properly
await addAssetsToQueue(); await addAssetsToQueue();
}) })
.catch(async (error) => { .catch(async () => {
logger.error(
'[PRELOAD] Failed to fetch presigned URLs, falling back to proxy',
{
error: error?.message,
},
);
// Fallback: add to queue without presigned URLs (will use backend proxy)
await addAssetsToQueue(); await addAssetsToQueue();
}); });
} else { } else {
// No storage paths to presign, add directly to queue
addAssetsToQueue(); addAssetsToQueue();
} }
}, [ }, [enabled, currentPageId, networkInfo.isOnline, elements, pages, pageLinks]);
enabled,
currentPageId, const isCurrentPageReady =
networkInfo.isOnline, currentPhase === 'phase2_transitions' || currentPhase === 'complete';
neighborGraph,
pages, const areTransitionsReady = currentPhase === 'complete';
pageLinks,
addToQueue,
maxNeighborDepth,
]);
return { return {
isPreloading, isPreloading,
@ -787,6 +645,10 @@ export function usePreloadOrchestrator(
getCachedBlobUrl, getCachedBlobUrl,
isUrlPreloaded, isUrlPreloaded,
getReadyBlobUrl, getReadyBlobUrl,
areNeighborBackgroundsReady, getReadyBlob,
currentPhase,
phaseProgress,
isCurrentPageReady,
areTransitionsReady,
}; };
} }

View File

@ -0,0 +1,159 @@
/**
* useSlideTransition Hook
*
* Manages slide transition animation for Gallery/Carousel elements.
* Implements fade-through-overlay: Slide 1 -> fade out -> overlay -> fade in -> Slide 2
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import type { CSSProperties } from 'react';
import type { ResolvedSlideTransition } from '../lib/resolveSlideTransition';
// Transition phases: idle -> fadingOut -> fadingIn -> idle
type TransitionPhase = 'idle' | 'fadingOut' | 'fadingIn';
interface SlideTransitionState {
currentIndex: number;
displayIndex: number; // What's actually shown (may differ during transition)
phase: TransitionPhase;
overlayOpacity: number; // 0 = hidden, 1 = fully visible
}
interface UseSlideTransitionReturn {
/** Current logical index */
currentIndex: number;
/** Index to display (follows currentIndex with delay during transition) */
displayIndex: number;
/** Current transition phase */
phase: TransitionPhase;
/** Whether any transition is active */
isTransitioning: boolean;
/** Overlay opacity (0-1) */
overlayOpacity: number;
/** Overlay color from settings */
overlayColor: string;
/** Navigate to specific slide index */
goToIndex: (index: number) => void;
/** Set initial index without transition */
setInitialIndex: (index: number) => void;
/** CSS transition style for slide image */
slideTransitionStyle: CSSProperties;
/** CSS transition style for overlay */
overlayTransitionStyle: CSSProperties;
/** Current slide opacity */
slideOpacity: number;
}
export function useSlideTransition(
settings: ResolvedSlideTransition,
): UseSlideTransitionReturn {
const [state, setState] = useState<SlideTransitionState>({
currentIndex: 0,
displayIndex: 0,
phase: 'idle',
overlayOpacity: 0,
});
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingIndexRef = useRef<number | null>(null);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
// Half duration for each phase (fade out + fade in)
const halfDuration = settings.durationMs / 2;
const goToIndex = useCallback(
(newIndex: number) => {
if (newIndex === state.currentIndex && state.phase === 'idle') return;
// Clear pending transition
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (settings.type === 'none') {
// Instant switch - no transition
setState({
currentIndex: newIndex,
displayIndex: newIndex,
phase: 'idle',
overlayOpacity: 0,
});
return;
}
// Store pending index
pendingIndexRef.current = newIndex;
// Phase 1: Fade out current slide (overlay fades in)
setState((prev) => ({
...prev,
currentIndex: newIndex,
phase: 'fadingOut',
overlayOpacity: 1,
}));
// Phase 2: At midpoint, switch display to new slide, start fade in
timeoutRef.current = setTimeout(() => {
setState((prev) => ({
...prev,
displayIndex: pendingIndexRef.current ?? prev.currentIndex,
phase: 'fadingIn',
overlayOpacity: 0,
}));
// Phase 3: Complete transition
timeoutRef.current = setTimeout(() => {
setState((prev) => ({
...prev,
phase: 'idle',
}));
pendingIndexRef.current = null;
}, halfDuration);
}, halfDuration);
},
[state.currentIndex, state.phase, settings.type, halfDuration],
);
const setInitialIndex = useCallback((index: number) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
pendingIndexRef.current = null;
setState({
currentIndex: index,
displayIndex: index,
phase: 'idle',
overlayOpacity: 0,
});
}, []);
// CSS transition styles
const slideTransitionStyle: CSSProperties =
settings.type === 'fade'
? { transition: `opacity ${halfDuration}ms ${settings.easing}` }
: {};
const overlayTransitionStyle: CSSProperties =
settings.type === 'fade'
? { transition: `opacity ${halfDuration}ms ${settings.easing}` }
: {};
// Slide opacity: visible in idle, fades based on phase
const slideOpacity = state.phase === 'fadingOut' ? 0 : 1;
return {
currentIndex: state.currentIndex,
displayIndex: state.displayIndex,
phase: state.phase,
isTransitioning: state.phase !== 'idle',
overlayOpacity: state.overlayOpacity,
overlayColor: settings.overlayColor,
goToIndex,
setInitialIndex,
slideTransitionStyle,
overlayTransitionStyle,
slideOpacity,
};
}

View File

@ -2,51 +2,45 @@
* useTransitionPlayback Hook * useTransitionPlayback Hook
* *
* Handles video transition playback between pages. * Handles video transition playback between pages.
* For back navigation, uses pre-reversed video generated by the backend.
* No frame-stepping - all transitions play forward.
*/ */
import { import {
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useReducer,
useRef, useRef,
useState,
type RefObject, type RefObject,
} from 'react'; } from 'react';
import axios from 'axios';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import {
markPresignedUrlFailed,
resolveAssetPlaybackUrl,
isPresignedUrl,
buildProxyUrl,
extractStoragePath,
} from '../lib/assetUrl';
import { downloadManager } from '../lib/offline/DownloadManager';
import { isSafari, isFirefox, scheduleAfterPaint } from '../lib/browserUtils'; import { isSafari, isFirefox, scheduleAfterPaint } from '../lib/browserUtils';
import { TRANSITION_CONFIG } from '../config/transition.config';
import {
useVideoBlobUrl,
useVideoTimeouts,
type PreloadCacheProvider,
} from './video';
export type ReverseMode = 'none' | 'separate'; export type ReverseMode = 'none' | 'separate';
export interface TransitionConfig { export interface TransitionConfig {
videoUrl: string; videoUrl: string;
storageKey?: string; // Raw storage path for cache lookup storageKey?: string;
reverseMode: ReverseMode; reverseMode: ReverseMode;
reverseVideoUrl?: string; reverseVideoUrl?: string;
/** Raw storage path for reverse video cache lookup */
reverseStorageKey?: string;
durationSec?: number; durationSec?: number;
targetPageId?: string; targetPageId?: string;
displayName?: string; displayName?: string;
/** Whether this is a back navigation (for history management) */
isBack?: boolean; isBack?: boolean;
} }
export interface UseTransitionPlaybackOptions { export interface UseTransitionPlaybackOptions {
videoRef: RefObject<HTMLVideoElement | null>; videoRef: RefObject<HTMLVideoElement | null>;
transition: TransitionConfig | null; transition: TransitionConfig | null;
/** Called when playback completes. isBack indicates if this was a back navigation. */
onComplete: (targetPageId?: string, isBack?: boolean) => void; onComplete: (targetPageId?: string, isBack?: boolean) => void;
onError?: (reason: string) => void; onError?: (reason: string) => void;
timeouts?: { timeouts?: {
playbackStartMs?: number; playbackStartMs?: number;
durationBufferMs?: number; durationBufferMs?: number;
@ -61,6 +55,7 @@ export interface UseTransitionPlaybackOptions {
preloadedUrls?: Set<string>; preloadedUrls?: Set<string>;
getCachedBlobUrl?: (url: string) => Promise<string | null>; getCachedBlobUrl?: (url: string) => Promise<string | null>;
getReadyBlobUrl?: (url: string) => string | null; getReadyBlobUrl?: (url: string) => string | null;
getReadyBlob?: (url: string) => Blob | null;
}; };
} }
@ -74,50 +69,83 @@ export type PlaybackPhase =
export interface UseTransitionPlaybackResult { export interface UseTransitionPlaybackResult {
phase: PlaybackPhase; phase: PlaybackPhase;
isBuffering: boolean; isBuffering: boolean;
/** True once first video frame has been displayed */
isVideoReady: boolean;
isReversing: boolean; isReversing: boolean;
cancel: () => void; cancel: () => void;
forceComplete: () => void; forceComplete: () => void;
} }
interface TransitionState {
phase: PlaybackPhase;
isVideoReady: boolean;
isWaitingForData: boolean;
activeSourceKey: string | null;
}
type TransitionAction =
| { type: 'START_PREPARING'; sourceKey: string }
| { type: 'SET_PLAYING' }
| { type: 'SET_VIDEO_READY' }
| { type: 'SET_WAITING'; isWaiting: boolean }
| { type: 'SET_FINISHING' }
| { type: 'SET_COMPLETED' }
| { type: 'RESET' };
function transitionReducer(
state: TransitionState,
action: TransitionAction,
): TransitionState {
switch (action.type) {
case 'START_PREPARING':
return {
...state,
phase: 'preparing',
isVideoReady: false,
isWaitingForData: false,
activeSourceKey: action.sourceKey,
};
case 'SET_PLAYING':
return { ...state, phase: 'playing' };
case 'SET_VIDEO_READY':
return { ...state, isVideoReady: true };
case 'SET_WAITING':
return { ...state, isWaitingForData: action.isWaiting };
case 'SET_FINISHING':
return { ...state, phase: 'finishing' };
case 'SET_COMPLETED':
return { ...state, phase: 'completed' };
case 'RESET':
return {
phase: 'idle',
isVideoReady: false,
isWaitingForData: false,
activeSourceKey: null,
};
default:
return state;
}
}
const initialState: TransitionState = {
phase: 'idle',
isVideoReady: false,
isWaitingForData: false,
activeSourceKey: null,
};
const DEFAULT_TIMEOUTS = { const DEFAULT_TIMEOUTS = {
playbackStartMs: 3000, playbackStartMs: 3000,
durationBufferMs: 200, durationBufferMs: 200,
hardTimeoutMs: 45000,
}; };
/**
* Get browser-specific finish offset for transition videos.
* This is a backup timer - requestVideoFrameCallback is primary for modern browsers.
*
* @returns Finish offset in milliseconds before video end
*/
const getFinishBeforeEndMs = (): number => { const getFinishBeforeEndMs = (): number => {
if (isSafari()) { const { finishBeforeEndMs } = TRANSITION_CONFIG;
return 350; if (isSafari()) return finishBeforeEndMs.safari;
} if (isFirefox()) return finishBeforeEndMs.firefox;
if (isFirefox()) { return finishBeforeEndMs.default;
return 300;
}
return 300;
}; };
function shouldLoadViaBlob(url: string, useBlobUrlOption?: boolean): boolean {
if (useBlobUrlOption === false) return false;
if (useBlobUrlOption === true) return true;
try {
const parsedUrl = new URL(url, window.location.origin);
const isSameOrigin = parsedUrl.origin === window.location.origin;
if (!isSameOrigin) return false;
return (
parsedUrl.pathname === '/api/file/download' ||
parsedUrl.pathname === '/file/download'
);
} catch {
return false;
}
}
async function waitForImages(urls: string[], timeoutMs = 2000): Promise<void> { async function waitForImages(urls: string[], timeoutMs = 2000): Promise<void> {
if (urls.length === 0) return; if (urls.length === 0) return;
@ -159,99 +187,79 @@ export function useTransitionPlayback(
const playbackStartMs = const playbackStartMs =
customTimeouts?.playbackStartMs ?? DEFAULT_TIMEOUTS.playbackStartMs; customTimeouts?.playbackStartMs ?? DEFAULT_TIMEOUTS.playbackStartMs;
const hardTimeoutMs =
customTimeouts?.hardTimeoutMs ?? DEFAULT_TIMEOUTS.hardTimeoutMs;
const [phase, setPhase] = useState<PlaybackPhase>('idle'); const [state, dispatch] = useReducer(transitionReducer, initialState);
const [isVideoReady, setIsVideoReady] = useState(false);
const didFinishRef = useRef(false); const didFinishRef = useRef(false);
const didStartPlaybackRef = useRef(false); const didStartPlaybackRef = useRef(false);
const activeSourceUrlRef = useRef<string | null>(null);
const lastLoadedBlobUrlRef = useRef<string | null>(null);
const lastLoadedSourceUrlRef = useRef<string | null>(null);
const didTryDecodeRetryRef = useRef(false); const didTryDecodeRetryRef = useRef(false);
const currentPlayableUrlRef = useRef<string | null>(null); const didSetInitialTimeRef = useRef(false);
const startWatchdogTimerRef = useRef<ReturnType<typeof setTimeout> | null>( const isWaitingForDataRef = useRef(false);
null, const lastProgressTimeRef = useRef<number>(0);
); const activeSourceKeyRef = useRef<string | null>(null);
const finishTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hardTimeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const onCompleteRef = useRef(onComplete); const onCompleteRef = useRef(onComplete);
const onErrorRef = useRef(onError); const onErrorRef = useRef(onError);
const transitionRef = useRef(transition); const transitionRef = useRef(transition);
const featuresRef = useRef(features); const featuresRef = useRef(features);
const preloadRef = useRef(preload);
// Determine which video URL to use: useEffect(() => {
// For back navigation with a reversed video, use reverseVideoUrl onCompleteRef.current = onComplete;
// Otherwise, use the original videoUrl onErrorRef.current = onError;
transitionRef.current = transition;
featuresRef.current = features;
});
const sourceUrl = useMemo(() => { const sourceUrl = useMemo(() => {
if (!transition) return ''; if (!transition) return '';
if (transition.isBack) { if (transition.isBack) return transition.reverseVideoUrl || '';
return transition.reverseVideoUrl || '';
}
return transition.videoUrl; return transition.videoUrl;
}, [transition]); }, [transition]);
// Storage key for cache lookup - use reversed video key for back navigation
const storageKey = useMemo(() => { const storageKey = useMemo(() => {
if (!transition) return undefined; if (!transition) return undefined;
if (transition.isBack) { // For back navigation, use reverseStorageKey (raw path) for cache lookup
return transition.reverseVideoUrl || undefined; if (transition.isBack) return transition.reverseStorageKey || undefined;
}
return transition.storageKey; return transition.storageKey;
}, [transition]); }, [transition]);
const clearTimers = useCallback(() => { const { setTimer, clearTimer, clearAllTimers } = useVideoTimeouts();
if (startWatchdogTimerRef.current) {
clearTimeout(startWatchdogTimerRef.current);
startWatchdogTimerRef.current = null;
}
if (finishTimerRef.current) {
clearTimeout(finishTimerRef.current);
finishTimerRef.current = null;
}
if (hardTimeoutTimerRef.current) {
clearTimeout(hardTimeoutTimerRef.current);
hardTimeoutTimerRef.current = null;
}
}, []);
const revokeBlobUrl = useCallback((force = false) => { const preloadCache: PreloadCacheProvider = useMemo(
if (!force || !lastLoadedBlobUrlRef.current) return; () => ({
URL.revokeObjectURL(lastLoadedBlobUrlRef.current); getReadyBlob: preload?.getReadyBlob,
lastLoadedBlobUrlRef.current = null; getCachedBlobUrl: preload?.getCachedBlobUrl,
}, []); }),
[preload?.getReadyBlob, preload?.getCachedBlobUrl],
);
const {
resolvedUrl,
isResolving,
revoke: revokeBlobUrl,
} = useVideoBlobUrl({
sourceUrl,
storageKey,
preloadCache,
});
const finishPlayback = useCallback( const finishPlayback = useCallback(
async (reason: string) => { async (reason: string) => {
if (didFinishRef.current) return; if (didFinishRef.current) return;
didFinishRef.current = true; didFinishRef.current = true;
activeSourceUrlRef.current = null; clearAllTimers();
clearTimers();
const video = videoRef.current; const video = videoRef.current;
if (video) { if (video) {
// Just pause - don't seek. We've already stopped at a safe frame
// (triggered by rvfc/timeupdate well before any black frames).
// Seeking is async and can cause a flash before the seek completes.
video.pause(); video.pause();
} }
const currentTransition = transitionRef.current; const currentTransition = transitionRef.current;
const currentFeatures = featuresRef.current; const currentFeatures = featuresRef.current;
logger.info('Transition playback finished', { logger.info('[TRANSITION] Finished', { reason });
reason,
displayName: currentTransition?.displayName,
targetPageId: currentTransition?.targetPageId,
});
setPhase('finishing'); dispatch({ type: 'SET_FINISHING' });
if ( if (
currentFeatures?.preDecodeImages && currentFeatures?.preDecodeImages &&
@ -262,50 +270,42 @@ export function useTransitionPlayback(
const imageUrls = currentFeatures.getTargetPageImages(); const imageUrls = currentFeatures.getTargetPageImages();
await waitForImages(imageUrls); await waitForImages(imageUrls);
} catch { } catch {
// Ignore pre-decode errors // Ignore
} }
} }
setPhase('completed'); dispatch({ type: 'SET_COMPLETED' });
onCompleteRef.current( onCompleteRef.current(
currentTransition?.targetPageId, currentTransition?.targetPageId,
currentTransition?.isBack, currentTransition?.isBack,
); );
}, },
[clearTimers, videoRef], [clearAllTimers, videoRef],
); );
const handleError = useCallback( const handleError = useCallback(
(reason: string) => { (reason: string) => {
if (didFinishRef.current) return; if (didFinishRef.current) return;
logger.error('Transition playback error', { reason }); logger.error('[TRANSITION] Error', { reason });
onErrorRef.current?.(reason); onErrorRef.current?.(reason);
finishPlayback(reason); finishPlayback(reason);
}, },
[finishPlayback], [finishPlayback],
); );
useEffect(() => {
onCompleteRef.current = onComplete;
onErrorRef.current = onError;
transitionRef.current = transition;
featuresRef.current = features;
preloadRef.current = preload;
});
const cancel = useCallback(() => { const cancel = useCallback(() => {
if (phase === 'idle') return; if (state.phase === 'idle') return;
clearTimers(); clearAllTimers();
didFinishRef.current = true; didFinishRef.current = true;
setPhase('idle'); dispatch({ type: 'RESET' });
const video = videoRef.current; const video = videoRef.current;
if (video) { if (video) {
video.pause(); video.pause();
video.removeAttribute('src'); video.removeAttribute('src');
video.load(); video.load();
} }
revokeBlobUrl(true); revokeBlobUrl();
}, [phase, clearTimers, videoRef, revokeBlobUrl]); }, [state.phase, clearAllTimers, videoRef, revokeBlobUrl]);
const forceComplete = useCallback(() => { const forceComplete = useCallback(() => {
finishPlayback('forced'); finishPlayback('forced');
@ -314,274 +314,68 @@ export function useTransitionPlayback(
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
const currentTransition = transitionRef.current; const currentTransition = transitionRef.current;
if (!currentTransition || !video) {
if (!currentTransition || !video || !resolvedUrl || isResolving) {
return; return;
} }
if (!sourceUrl) { if (!sourceUrl) {
logger.info('No playable transition source, skipping playback', {
isBack: currentTransition.isBack,
targetPageId: currentTransition.targetPageId,
});
void finishPlayback('missing-source'); void finishPlayback('missing-source');
return; return;
} }
// Include isBack in the key so same video can play forward or as reversed
const sourceKey = `${sourceUrl}|${currentTransition.isBack ? 'back' : 'forward'}`; const sourceKey = `${sourceUrl}|${currentTransition.isBack ? 'back' : 'forward'}`;
if (activeSourceUrlRef.current === sourceKey) { if (activeSourceKeyRef.current === sourceKey) {
logger.info('Skipping duplicate effect for same source', {
sourceUrl,
isBack: currentTransition.isBack,
});
return; return;
} }
activeSourceUrlRef.current = sourceKey; activeSourceKeyRef.current = sourceKey;
dispatch({ type: 'START_PREPARING', sourceKey });
didFinishRef.current = false; didFinishRef.current = false;
didStartPlaybackRef.current = false; didStartPlaybackRef.current = false;
didTryDecodeRetryRef.current = false; didTryDecodeRetryRef.current = false;
currentPlayableUrlRef.current = null; didSetInitialTimeRef.current = false;
setPhase('preparing'); isWaitingForDataRef.current = false;
lastProgressTimeRef.current = Date.now();
const configuredDurationSec = Number(currentTransition.durationSec); const configuredDurationSec = Number(currentTransition.durationSec);
const getMediaErrorDetails = () => { const getMediaErrorDetails = () => {
if (!video.error) return null; if (!video.error) return null;
const mediaError = video.error as MediaError & { message?: string }; const mediaError = video.error as MediaError & { message?: string };
return { return { code: mediaError.code, message: mediaError.message || '' };
code: mediaError.code,
message: mediaError.message || '',
};
}; };
const logIssue = (reason: string, error?: unknown) => { const logIssue = (reason: string) => {
logger.error('Transition playback issue:', { logger.error('[TRANSITION] Issue', {
reason, reason,
src: video.currentSrc || sourceUrl,
readyState: video.readyState, readyState: video.readyState,
networkState: video.networkState, networkState: video.networkState,
duration: video.duration,
configuredDurationSec,
isBack: currentTransition.isBack,
mediaError: getMediaErrorDetails(), mediaError: getMediaErrorDetails(),
error: error instanceof Error ? error : { error },
}); });
}; };
const scheduleFinishByDuration = (durationSec: number) => { const scheduleFinishByDuration = (durationSec: number) => {
if ( if (!Number.isFinite(durationSec) || durationSec <= 0) return;
!Number.isFinite(durationSec) ||
durationSec <= 0 ||
finishTimerRef.current
) {
return;
}
// Use browser-specific offset to prevent black flash at video end
const finishBeforeEndMs = getFinishBeforeEndMs(); const finishBeforeEndMs = getFinishBeforeEndMs();
const finishMs = Math.max(100, durationSec * 1000 - finishBeforeEndMs); const finishMs = Math.max(100, durationSec * 1000 - finishBeforeEndMs);
finishTimerRef.current = setTimeout( setTimer('finishByDuration', () => finishPlayback('duration-timer'), finishMs);
() => finishPlayback('duration-timer'),
finishMs,
);
}; };
const attemptPlay = () => { const attemptPlay = () => {
video.play().catch((playError) => { video.play().catch((playError) => {
logIssue('play-failed', playError); logIssue('play-failed');
logger.error('[TRANSITION] Play failed', { error: playError });
}); });
}; };
const resolvePlayableSource = async (): Promise<string> => {
const getReadyBlobUrl = preloadRef.current?.getReadyBlobUrl;
const currentStorageKey = storageKey;
// 1. Try storage key lookup first (most reliable for cache hits)
if (getReadyBlobUrl && currentStorageKey) {
const readyUrl = getReadyBlobUrl(currentStorageKey);
if (readyUrl) {
logger.info('Using ready blob URL from storage key', {
storageKey: currentStorageKey.slice(-50),
});
return readyUrl;
}
}
// 2. Try cached blob URL by storage key
const getCachedBlobUrl = preloadRef.current?.getCachedBlobUrl;
if (getCachedBlobUrl && currentStorageKey) {
try {
const cachedBlobUrl = await getCachedBlobUrl(currentStorageKey);
if (cachedBlobUrl) {
logger.info('Using cached blob URL from storage key', {
storageKey: currentStorageKey.slice(-50),
});
lastLoadedBlobUrlRef.current = cachedBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return cachedBlobUrl;
}
} catch {
// Fall through
}
}
// 3. Reuse cached blob URL if same source
if (
lastLoadedBlobUrlRef.current &&
lastLoadedSourceUrlRef.current === sourceUrl
) {
logger.info('Reusing cached blob URL');
return lastLoadedBlobUrlRef.current;
}
if (
lastLoadedBlobUrlRef.current &&
lastLoadedSourceUrlRef.current !== sourceUrl
) {
revokeBlobUrl(true);
}
const needsBlobUrl = shouldLoadViaBlob(
sourceUrl,
featuresRef.current?.useBlobUrl,
);
if (!needsBlobUrl) {
return sourceUrl;
}
// 4. Try ready blob URL by resolved URL
if (getReadyBlobUrl) {
const readyUrl = getReadyBlobUrl(sourceUrl);
if (readyUrl) {
logger.info('Using ready blob URL from resolved URL', {
url: sourceUrl.slice(-50),
});
return readyUrl;
}
}
// 5. Try cached blob URL by resolved URL
if (getCachedBlobUrl) {
try {
const cachedBlobUrl = await getCachedBlobUrl(sourceUrl);
if (cachedBlobUrl) {
logger.info('Using preloaded blob URL from cache', {
isBack: currentTransition.isBack,
});
lastLoadedBlobUrlRef.current = cachedBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return cachedBlobUrl;
}
} catch (cacheError) {
logger.warn('Cache lookup failed, falling back to fetch', {
cacheError,
});
}
}
// 6. Fetch video as blob
logger.info('Fetching video as blob', {
isBack: currentTransition.isBack,
});
const freshUrl = currentStorageKey
? resolveAssetPlaybackUrl(currentStorageKey)
: sourceUrl;
const token =
typeof window !== 'undefined'
? localStorage.getItem('token') || ''
: '';
const fetchVideoAsBlob = async (url: string): Promise<string> => {
logger.info('Fetching video from URL', {
url: url.slice(0, 80),
isPresigned: isPresignedUrl(url),
});
const response = await axios.get(url, {
responseType: 'blob',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
baseURL: '',
});
const blob = response.data as Blob;
if (currentStorageKey) {
const normalizedKey = extractStoragePath(currentStorageKey);
const blobUrl = await downloadManager.cacheBlob(normalizedKey, blob, {
assetType: 'transition',
});
lastLoadedBlobUrlRef.current = blobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return blobUrl;
}
const blobUrl = URL.createObjectURL(blob);
lastLoadedBlobUrlRef.current = blobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
logger.info('Created blob URL for video (no caching)', {
blobUrl: blobUrl.substring(0, 50),
});
return blobUrl;
};
try {
return await fetchVideoAsBlob(freshUrl);
} catch (error) {
if (currentStorageKey && isPresignedUrl(freshUrl)) {
logger.info('Presigned URL failed, retrying with proxy', {
storageKey: currentStorageKey.slice(-40),
});
markPresignedUrlFailed(currentStorageKey);
const proxyUrl = buildProxyUrl(currentStorageKey);
return await fetchVideoAsBlob(proxyUrl);
}
throw error;
}
};
const loadAndPlay = async () => {
logger.info('loadAndPlay called', {
isBack: currentTransition.isBack,
sourceUrl,
});
setIsVideoReady(false); // Reset for new playback
didStartPlaybackRef.current = false;
if (startWatchdogTimerRef.current) {
clearTimeout(startWatchdogTimerRef.current);
}
try {
const playableSourceUrl = await resolvePlayableSource();
if (didFinishRef.current) return;
video.pause();
video.src = playableSourceUrl;
video.currentTime = 0;
video.load();
lastLoadedSourceUrlRef.current = playableSourceUrl;
currentPlayableUrlRef.current = playableSourceUrl;
attemptPlay();
startWatchdogTimerRef.current = setTimeout(() => {
if (didStartPlaybackRef.current || didFinishRef.current) return;
logIssue('playback-start-slow');
attemptPlay();
}, playbackStartMs);
} catch (error) {
logIssue('source-prepare-failed', error);
handleError('source-prepare-failed');
}
};
const onLoadedMetadata = () => { const onLoadedMetadata = () => {
if (didFinishRef.current) return; if (didFinishRef.current) return;
video.currentTime = 0; // Only reset to start once - prevents streaming chunks from resetting position
if (!didSetInitialTimeRef.current) {
didSetInitialTimeRef.current = true;
video.currentTime = 0;
}
attemptPlay(); attemptPlay();
}; };
@ -591,63 +385,51 @@ export function useTransitionPlayback(
}; };
const onPlaying = () => { const onPlaying = () => {
logger.info('onPlaying fired', {
isBack: currentTransition.isBack,
didStartPlayback: didStartPlaybackRef.current,
didFinish: didFinishRef.current,
});
if (didFinishRef.current) return; if (didFinishRef.current) return;
didStartPlaybackRef.current = true; if (isWaitingForDataRef.current) {
setPhase('playing'); dispatch({ type: 'SET_WAITING', isWaiting: false });
isWaitingForDataRef.current = false;
if (startWatchdogTimerRef.current) {
clearTimeout(startWatchdogTimerRef.current);
startWatchdogTimerRef.current = null;
} }
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+) didStartPlaybackRef.current = true;
// This fires when a frame is actually sent to the compositor - no guesswork dispatch({ type: 'SET_PLAYING' });
clearTimer('playbackWatchdog');
if ('requestVideoFrameCallback' in video) { if ('requestVideoFrameCallback' in video) {
const rvfc = video.requestVideoFrameCallback.bind(video); const rvfc = video.requestVideoFrameCallback.bind(video);
// First callback: frame is composited, safe to show overlay rvfc(() => {
rvfc((_now, _metadata) => {
if (!didFinishRef.current) { if (!didFinishRef.current) {
setIsVideoReady(true); dispatch({ type: 'SET_VIDEO_READY' });
} }
// Monitor video position to finish before end (prevents END flash)
// Note: rvfc fires AFTER frame is composited, so we need extra buffer
const monitorEnd = ( const monitorEnd = (
_now2: number, _now: number,
metadata: VideoFrameCallbackMetadata, metadata: VideoFrameCallbackMetadata,
) => { ) => {
if (didFinishRef.current) return; if (didFinishRef.current) return;
const duration = video.duration; const duration = video.duration;
// Finish 300ms before end - gives margin for black/fade frames const { rvfcThreshold } = TRANSITION_CONFIG;
// that some videos have in the last 100-200ms
if ( if (
Number.isFinite(duration) && Number.isFinite(duration) &&
metadata.mediaTime >= duration - 0.3 metadata.mediaTime >= duration - rvfcThreshold
) { ) {
finishPlayback('rvfc-end'); finishPlayback('rvfc-end');
return; return;
} }
// Continue monitoring each frame
rvfc(monitorEnd); rvfc(monitorEnd);
}; };
rvfc(monitorEnd); rvfc(monitorEnd);
}); });
} else { } else {
// Fallback for older browsers without requestVideoFrameCallback
scheduleAfterPaint(() => { scheduleAfterPaint(() => {
if (!didFinishRef.current) { if (!didFinishRef.current) {
setIsVideoReady(true); dispatch({ type: 'SET_VIDEO_READY' });
} }
}); });
} }
@ -659,6 +441,7 @@ export function useTransitionPlayback(
: Number.isFinite(mediaDurationSec) && mediaDurationSec > 0 : Number.isFinite(mediaDurationSec) && mediaDurationSec > 0
? mediaDurationSec ? mediaDurationSec
: NaN; : NaN;
if (Number.isFinite(durationSec) && durationSec > 0) { if (Number.isFinite(durationSec) && durationSec > 0) {
scheduleFinishByDuration(durationSec); scheduleFinishByDuration(durationSec);
} }
@ -668,30 +451,28 @@ export function useTransitionPlayback(
finishPlayback('ended'); finishPlayback('ended');
}; };
// Backup handler for browsers without requestVideoFrameCallback
// Note: timeupdate fires infrequently (~250ms in Safari), so this is just a fallback
const onTimeUpdate = () => { const onTimeUpdate = () => {
if (didFinishRef.current) return; if (didFinishRef.current) return;
const duration = video.duration; const duration = video.duration;
if (!Number.isFinite(duration)) return; if (!Number.isFinite(duration)) return;
// Large buffer since timeupdate is infrequent const { timeUpdateSafetyBuffer } = TRANSITION_CONFIG;
// Safari: 600ms, Others: 400ms const safetyBuffer = isSafari()
const safetyBuffer = isSafari() ? 0.6 : 0.4; ? timeUpdateSafetyBuffer.safari
: timeUpdateSafetyBuffer.default;
if (video.currentTime >= duration - safetyBuffer) { if (video.currentTime >= duration - safetyBuffer) {
finishPlayback('timeupdate-end'); finishPlayback('timeupdate-end');
} }
}; };
const onVideoError = async () => { const onVideoError = () => {
if (didFinishRef.current) return; if (didFinishRef.current) return;
logIssue('video-error'); logIssue('video-error');
const errorCode = video.error?.code; const errorCode = video.error?.code;
if (errorCode === 3 && !didTryDecodeRetryRef.current) { if (errorCode === 3 && !didTryDecodeRetryRef.current) {
logger.info('Safari video decode error, attempting reload');
didTryDecodeRetryRef.current = true; didTryDecodeRetryRef.current = true;
video.load(); video.load();
attemptPlay(); attemptPlay();
@ -712,6 +493,42 @@ export function useTransitionPlayback(
logIssue('video-stalled'); logIssue('video-stalled');
}; };
const onWaiting = () => {
if (didFinishRef.current) return;
dispatch({ type: 'SET_WAITING', isWaiting: true });
isWaitingForDataRef.current = true;
};
const onProgress = () => {
if (didFinishRef.current) return;
lastProgressTimeRef.current = Date.now();
};
const { progressTimeout } = TRANSITION_CONFIG;
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const actualNoProgressMs = isMobile
? progressTimeout.noProgressMs * progressTimeout.mobileMultiplier
: progressTimeout.noProgressMs;
const checkProgress = () => {
if (didFinishRef.current) return;
const timeSinceProgress = Date.now() - lastProgressTimeRef.current;
if (!video.paused && !isWaitingForDataRef.current) {
setTimer('progressCheck', checkProgress, progressTimeout.checkIntervalMs);
return;
}
if (timeSinceProgress < actualNoProgressMs) {
setTimer('progressCheck', checkProgress, progressTimeout.checkIntervalMs);
return;
}
logger.error('[TRANSITION] No progress timeout');
handleError('no-progress-timeout');
};
video.addEventListener('loadedmetadata', onLoadedMetadata); video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('canplay', onCanPlay); video.addEventListener('canplay', onCanPlay);
video.addEventListener('playing', onPlaying); video.addEventListener('playing', onPlaying);
@ -720,14 +537,33 @@ export function useTransitionPlayback(
video.addEventListener('error', onVideoError); video.addEventListener('error', onVideoError);
video.addEventListener('abort', onAbort); video.addEventListener('abort', onAbort);
video.addEventListener('stalled', onStalled); video.addEventListener('stalled', onStalled);
video.addEventListener('waiting', onWaiting);
video.addEventListener('progress', onProgress);
hardTimeoutTimerRef.current = setTimeout(() => { logger.info('[TRANSITION] Starting', {
if (didFinishRef.current) return; url: resolvedUrl.slice(-60),
logIssue('hard-timeout'); isBack: currentTransition.isBack,
handleError('hard-timeout'); });
}, hardTimeoutMs);
void loadAndPlay(); video.pause();
video.src = resolvedUrl;
video.currentTime = 0;
didSetInitialTimeRef.current = true; // Prevent loadedmetadata from resetting during streaming
video.load();
attemptPlay();
setTimer(
'playbackWatchdog',
() => {
if (!didStartPlaybackRef.current && !didFinishRef.current) {
logIssue('playback-start-slow');
attemptPlay();
}
},
playbackStartMs,
);
setTimer('progressCheck', checkProgress, progressTimeout.checkIntervalMs);
return () => { return () => {
video.removeEventListener('loadedmetadata', onLoadedMetadata); video.removeEventListener('loadedmetadata', onLoadedMetadata);
@ -738,37 +574,40 @@ export function useTransitionPlayback(
video.removeEventListener('error', onVideoError); video.removeEventListener('error', onVideoError);
video.removeEventListener('abort', onAbort); video.removeEventListener('abort', onAbort);
video.removeEventListener('stalled', onStalled); video.removeEventListener('stalled', onStalled);
clearTimers(); video.removeEventListener('waiting', onWaiting);
video.removeEventListener('progress', onProgress);
clearAllTimers();
}; };
}, [ }, [
sourceUrl, sourceUrl,
storageKey, resolvedUrl,
transition?.isBack, isResolving,
videoRef, videoRef,
playbackStartMs, playbackStartMs,
hardTimeoutMs, setTimer,
clearTimers, clearTimer,
revokeBlobUrl, clearAllTimers,
finishPlayback, finishPlayback,
handleError, handleError,
]); ]);
useEffect(() => { useEffect(() => {
if (!transition) { if (!transition) {
setPhase('idle'); dispatch({ type: 'RESET' });
setIsVideoReady(false);
activeSourceUrlRef.current = null;
didFinishRef.current = false; didFinishRef.current = false;
didStartPlaybackRef.current = false; didStartPlaybackRef.current = false;
activeSourceKeyRef.current = null;
} }
}, [transition]); }, [transition]);
return { return {
phase, phase: state.phase,
// Show buffering until video first frame is painted (prevents START black flash)
isBuffering: isBuffering:
phase === 'preparing' || (phase === 'playing' && !isVideoReady), state.phase === 'preparing' ||
isReversing: false, // No longer support frame-stepping reverse (state.phase === 'playing' && !state.isVideoReady) ||
(state.phase === 'playing' && state.isWaitingForData),
isVideoReady: state.isVideoReady,
isReversing: false,
cancel, cancel,
forceComplete, forceComplete,
}; };

View File

@ -7,6 +7,7 @@
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import type { TransitionPreviewState } from '../types/presentation'; import type { TransitionPreviewState } from '../types/presentation';
import { logger } from '../lib/logger';
export type { TransitionPreviewState }; export type { TransitionPreviewState };
@ -128,6 +129,14 @@ export function useTransitionPreview({
isBack: direction === 'back', // Track for history management isBack: direction === 'back', // Track for history management
}; };
logger.info('[TRANSITION-PREVIEW] Setting preview state', {
videoUrl: previewState.videoUrl?.slice(-60),
storageKey: previewState.storageKey?.slice(-60),
reverseMode: previewState.reverseMode,
direction,
isBack: previewState.isBack,
});
setPreview(previewState); setPreview(previewState);
}, },
[isNavigationElementType, onError], [isNavigationElementType, onError],

View File

@ -0,0 +1,175 @@
/**
* useTransitionSettings Hook
*
* Resolves transition settings by cascading through three levels:
* Element Project Global (fallback)
*
* Video transitions always take precedence over CSS-based transitions.
*/
import { useMemo } from 'react';
import type {
GlobalTransitionDefaults,
ProjectTransitionSettings,
ElementTransitionSettings,
ResolvedTransitionSettings,
TransitionType,
EasingFunction,
DEFAULT_TRANSITION_SETTINGS,
} from '../types/transition';
export interface UseTransitionSettingsParams {
/** Global defaults (from global_transition_defaults table) */
globalDefaults: GlobalTransitionDefaults | null;
/** Project-level settings (from project_transition_settings table, environment-aware) */
projectSettings?: ProjectTransitionSettings | null;
/** Element-level settings (from ui_schema_json) */
elementSettings?: ElementTransitionSettings | null;
}
/**
* Default values used when no settings are available at any level
*/
const FALLBACK_DEFAULTS = {
type: 'fade' as TransitionType,
durationMs: 700,
easing: 'ease-in-out' as EasingFunction,
overlayColor: '#000000',
};
/**
* Hook to resolve transition settings with cascade:
* Element Project Global Fallback defaults
*
* @example
* ```tsx
* // Project settings are fetched from project_transition_settings store
* // and converted using entityToProjectSettings() helper
* const transitionSettings = useTransitionSettings({
* globalDefaults,
* projectSettings, // from projectTransitionSettingsSlice (environment-aware)
* elementSettings: element?.transitionSettings,
* });
*
* // Use resolved settings
* if (transitionSettings.type === 'video') {
* // Play video transition
* } else {
* // Apply CSS transition with transitionSettings.durationMs and transitionSettings.easing
* }
* ```
*/
export function useTransitionSettings({
globalDefaults,
projectSettings,
elementSettings,
}: UseTransitionSettingsParams): ResolvedTransitionSettings {
return useMemo(() => {
// Video transitions always take precedence
if (elementSettings?.transitionVideoUrl) {
return {
type: 'video' as TransitionType,
durationMs:
elementSettings.transitionDurationMs ?? FALLBACK_DEFAULTS.durationMs,
easing: elementSettings.transitionEasing ?? FALLBACK_DEFAULTS.easing,
overlayColor:
elementSettings.transitionOverlayColor ??
projectSettings?.overlayColor ??
globalDefaults?.overlay_color ??
FALLBACK_DEFAULTS.overlayColor,
videoUrl: elementSettings.transitionVideoUrl,
reverseVideoUrl: elementSettings.reverseVideoUrl,
};
}
// Cascade: Element → Project → Global → Fallback
const type: TransitionType =
elementSettings?.transitionType ??
projectSettings?.transitionType ??
globalDefaults?.transition_type ??
FALLBACK_DEFAULTS.type;
const durationMs: number =
elementSettings?.transitionDurationMs ??
projectSettings?.durationMs ??
globalDefaults?.duration_ms ??
FALLBACK_DEFAULTS.durationMs;
const easing: EasingFunction =
elementSettings?.transitionEasing ??
projectSettings?.easing ??
globalDefaults?.easing ??
FALLBACK_DEFAULTS.easing;
const overlayColor: string =
elementSettings?.transitionOverlayColor ??
projectSettings?.overlayColor ??
globalDefaults?.overlay_color ??
FALLBACK_DEFAULTS.overlayColor;
return {
type,
durationMs,
easing,
overlayColor,
};
}, [globalDefaults, projectSettings, elementSettings]);
}
/**
* Resolve transition settings without React hook (for non-component contexts)
*/
export function resolveTransitionSettings({
globalDefaults,
projectSettings,
elementSettings,
}: UseTransitionSettingsParams): ResolvedTransitionSettings {
// Video transitions always take precedence
if (elementSettings?.transitionVideoUrl) {
return {
type: 'video' as TransitionType,
durationMs:
elementSettings.transitionDurationMs ?? FALLBACK_DEFAULTS.durationMs,
easing: elementSettings.transitionEasing ?? FALLBACK_DEFAULTS.easing,
overlayColor:
elementSettings.transitionOverlayColor ??
projectSettings?.overlayColor ??
globalDefaults?.overlay_color ??
FALLBACK_DEFAULTS.overlayColor,
videoUrl: elementSettings.transitionVideoUrl,
reverseVideoUrl: elementSettings.reverseVideoUrl,
};
}
// Cascade: Element → Project → Global → Fallback
const type: TransitionType =
elementSettings?.transitionType ??
projectSettings?.transitionType ??
globalDefaults?.transition_type ??
FALLBACK_DEFAULTS.type;
const durationMs: number =
elementSettings?.transitionDurationMs ??
projectSettings?.durationMs ??
globalDefaults?.duration_ms ??
FALLBACK_DEFAULTS.durationMs;
const easing: EasingFunction =
elementSettings?.transitionEasing ??
projectSettings?.easing ??
globalDefaults?.easing ??
FALLBACK_DEFAULTS.easing;
const overlayColor: string =
elementSettings?.transitionOverlayColor ??
projectSettings?.overlayColor ??
globalDefaults?.overlay_color ??
FALLBACK_DEFAULTS.overlayColor;
return {
type,
durationMs,
easing,
overlayColor,
};
}

View File

@ -0,0 +1,59 @@
/**
* Video Hooks Module
*
* Primitive hooks for video playback management.
* These hooks can be composed to build various video playback scenarios.
*/
// Primitive hooks
export {
useVideoEventManager,
type VideoEventType,
type VideoEventHandler,
type VideoEventHandlers,
type UseVideoEventManagerOptions,
} from './useVideoEventManager';
export {
useVideoBufferingState,
type UseVideoBufferingStateOptions,
type UseVideoBufferingStateResult,
} from './useVideoBufferingState';
export {
useVideoBlobUrl,
type PreloadCacheProvider,
type UseVideoBlobUrlOptions,
type UseVideoBlobUrlResult,
} from './useVideoBlobUrl';
export {
useVideoFirstFrame,
type UseVideoFirstFrameOptions,
type UseVideoFirstFrameResult,
} from './useVideoFirstFrame';
export {
useVideoErrorRecovery,
type UseVideoErrorRecoveryOptions,
type UseVideoErrorRecoveryResult,
} from './useVideoErrorRecovery';
export {
useVideoTimeouts,
type UseVideoTimeoutsResult,
} from './useVideoTimeouts';
// Composite hook
export {
useVideoPlaybackCore,
type UseVideoPlaybackCoreOptions,
type UseVideoPlaybackCoreResult,
} from './useVideoPlaybackCore';
// Video player hook for UI elements
export {
useVideoPlayer,
type UseVideoPlayerOptions,
type UseVideoPlayerResult,
} from './useVideoPlayer';

View File

@ -0,0 +1,185 @@
/**
* useVideoBlobUrl Hook
*
* Resolves video URLs with multi-tier fallback:
* 1. Fresh blob URL from cached Blob
* 2. Cached blob URL by storage key
* 3. Proxy URL fallback
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { logger } from '../../lib/logger';
import { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
export interface PreloadCacheProvider {
getReadyBlob?: (key: string) => Blob | null;
getCachedBlobUrl?: (key: string) => Promise<string | null>;
}
export interface UseVideoBlobUrlOptions {
sourceUrl: string;
storageKey?: string;
preloadCache?: PreloadCacheProvider;
onResolved?: (url: string) => void;
onError?: (error: Error) => void;
}
export interface UseVideoBlobUrlResult {
resolvedUrl: string | null;
isResolving: boolean;
error: Error | null;
resolve: () => Promise<string>;
revoke: () => void;
}
export function useVideoBlobUrl({
sourceUrl,
storageKey,
preloadCache,
onResolved,
onError,
}: UseVideoBlobUrlOptions): UseVideoBlobUrlResult {
const [resolvedUrl, setResolvedUrl] = useState<string | null>(null);
const [isResolving, setIsResolving] = useState(false);
const [error, setError] = useState<Error | null>(null);
const lastLoadedBlobUrlRef = useRef<string | null>(null);
const lastLoadedSourceUrlRef = useRef<string | null>(null);
const onResolvedRef = useRef(onResolved);
const onErrorRef = useRef(onError);
useEffect(() => {
onResolvedRef.current = onResolved;
onErrorRef.current = onError;
});
const revoke = useCallback(() => {
if (lastLoadedBlobUrlRef.current) {
URL.revokeObjectURL(lastLoadedBlobUrlRef.current);
lastLoadedBlobUrlRef.current = null;
}
}, []);
const resolve = useCallback(async (): Promise<string> => {
const { getReadyBlob, getCachedBlobUrl } = preloadCache || {};
// 1. Try raw Blob by storage key
if (getReadyBlob && storageKey) {
const blob = getReadyBlob(storageKey);
if (blob) {
revoke();
const freshBlobUrl = URL.createObjectURL(blob);
lastLoadedBlobUrlRef.current = freshBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return freshBlobUrl;
}
}
// 2. Try cached blob URL by storage key
if (getCachedBlobUrl && storageKey) {
try {
const cachedBlobUrl = await getCachedBlobUrl(storageKey);
if (cachedBlobUrl) {
lastLoadedBlobUrlRef.current = cachedBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return cachedBlobUrl;
}
} catch {
// Fall through
}
}
// 3. Reuse cached blob URL if same source
if (
lastLoadedBlobUrlRef.current &&
lastLoadedSourceUrlRef.current === sourceUrl
) {
return lastLoadedBlobUrlRef.current;
}
if (lastLoadedBlobUrlRef.current) {
revoke();
}
// 4. Try raw Blob by source URL
if (getReadyBlob) {
const blob = getReadyBlob(sourceUrl);
if (blob) {
const freshBlobUrl = URL.createObjectURL(blob);
lastLoadedBlobUrlRef.current = freshBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return freshBlobUrl;
}
}
// 5. Try cached blob URL by source URL
if (getCachedBlobUrl) {
try {
const cachedBlobUrl = await getCachedBlobUrl(sourceUrl);
if (cachedBlobUrl) {
lastLoadedBlobUrlRef.current = cachedBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return cachedBlobUrl;
}
} catch {
// Fall through
}
}
// 6. Fallback to proxy URL
const downloadKey = storageKey || sourceUrl;
const proxyUrl = resolveAssetPlaybackUrl(downloadKey);
logger.info('[VIDEO-URL] Using proxy URL', {
storageKey: downloadKey.slice(-60),
});
return proxyUrl;
}, [sourceUrl, storageKey, preloadCache, revoke]);
useEffect(() => {
if (!sourceUrl) {
setResolvedUrl(null);
return;
}
let cancelled = false;
setIsResolving(true);
setError(null);
resolve()
.then((url) => {
if (!cancelled) {
setResolvedUrl(url);
setIsResolving(false);
onResolvedRef.current?.(url);
}
})
.catch((err) => {
if (!cancelled) {
const resolveError = err instanceof Error ? err : new Error(String(err));
setError(resolveError);
setIsResolving(false);
onErrorRef.current?.(resolveError);
}
});
return () => {
cancelled = true;
};
}, [sourceUrl, resolve]);
useEffect(() => {
return () => {
revoke();
};
}, [revoke]);
return {
resolvedUrl,
isResolving,
error,
resolve,
revoke,
};
}
export default useVideoBlobUrl;

View File

@ -0,0 +1,216 @@
/**
* useVideoBufferingState Hook
*
* Tracks video buffering state including:
* - Initial buffering (waiting for canplay)
* - Mid-playback buffering (waiting event)
* - Progress-based timeout detection
*/
import { useState, useRef, useCallback, useEffect, type RefObject } from 'react';
import { logger } from '../../lib/logger';
import { TRANSITION_CONFIG } from '../../config/transition.config';
export interface UseVideoBufferingStateOptions {
videoRef: RefObject<HTMLVideoElement | null>;
enabled?: boolean;
/** Custom no-progress timeout in ms (uses config default if not provided) */
noProgressMs?: number;
/** Custom check interval in ms (uses config default if not provided) */
checkIntervalMs?: number;
/** Callback when buffering state changes */
onBufferingChange?: (isBuffering: boolean) => void;
/** Callback when progress timeout occurs */
onProgressTimeout?: () => void;
}
export interface UseVideoBufferingStateResult {
/** True when video is waiting for data (initial load or mid-playback) */
isBuffering: boolean;
/** True specifically when waiting event has fired (mid-playback) */
isWaitingForData: boolean;
/** True when canplay has fired (enough data to start playing) */
isReady: boolean;
/** Reset buffering state (e.g., when loading new source) */
reset: () => void;
/** Start progress monitoring (call after video starts loading) */
startProgressMonitor: () => void;
/** Stop progress monitoring */
stopProgressMonitor: () => void;
/** Update last progress time (call on progress events) */
updateProgressTime: () => void;
}
/**
* Hook for tracking video buffering state.
*
* Handles both initial buffering (before canplay) and mid-playback
* buffering (when video is waiting for more data).
*
* @example
* const { isBuffering, isReady, startProgressMonitor } = useVideoBufferingState({
* videoRef,
* enabled: true,
* onProgressTimeout: () => console.error('Video timed out'),
* });
*/
export function useVideoBufferingState({
videoRef,
enabled = true,
noProgressMs,
checkIntervalMs,
onBufferingChange,
onProgressTimeout,
}: UseVideoBufferingStateOptions): UseVideoBufferingStateResult {
const [isReady, setIsReady] = useState(false);
const [isWaitingForData, setIsWaitingForData] = useState(false);
const isWaitingForDataRef = useRef(false);
const lastProgressTimeRef = useRef<number>(0);
const progressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isMonitoringRef = useRef(false);
const onProgressTimeoutRef = useRef(onProgressTimeout);
const onBufferingChangeRef = useRef(onBufferingChange);
// Update refs on each render
useEffect(() => {
onProgressTimeoutRef.current = onProgressTimeout;
onBufferingChangeRef.current = onBufferingChange;
});
// Calculate actual timeout values
const { progressTimeout } = TRANSITION_CONFIG;
const isMobile =
typeof navigator !== 'undefined' &&
/iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const actualNoProgressMs =
noProgressMs ??
(isMobile
? progressTimeout.noProgressMs * progressTimeout.mobileMultiplier
: progressTimeout.noProgressMs);
const actualCheckIntervalMs = checkIntervalMs ?? progressTimeout.checkIntervalMs;
const stopProgressMonitor = useCallback(() => {
if (progressTimeoutRef.current) {
clearTimeout(progressTimeoutRef.current);
progressTimeoutRef.current = null;
}
isMonitoringRef.current = false;
}, []);
const updateProgressTime = useCallback(() => {
lastProgressTimeRef.current = Date.now();
}, []);
const startProgressMonitor = useCallback(() => {
if (isMonitoringRef.current) return;
isMonitoringRef.current = true;
lastProgressTimeRef.current = Date.now();
const video = videoRef.current;
const checkProgress = () => {
if (!isMonitoringRef.current) return;
if (!video) return;
const timeSinceProgress = Date.now() - lastProgressTimeRef.current;
// If playing normally (not waiting), continue monitoring
if (!video.paused && !isWaitingForDataRef.current) {
progressTimeoutRef.current = setTimeout(checkProgress, actualCheckIntervalMs);
return;
}
// If waiting but received data recently, keep waiting
if (timeSinceProgress < actualNoProgressMs) {
progressTimeoutRef.current = setTimeout(checkProgress, actualCheckIntervalMs);
return;
}
// No progress for too long while waiting - timeout
logger.error('useVideoBufferingState: No network progress - timing out', {
timeSinceProgress,
currentTime: video.currentTime?.toFixed(2),
isWaiting: isWaitingForDataRef.current,
actualNoProgressMs,
});
onProgressTimeoutRef.current?.();
};
progressTimeoutRef.current = setTimeout(checkProgress, actualCheckIntervalMs);
}, [videoRef, actualNoProgressMs, actualCheckIntervalMs]);
const reset = useCallback(() => {
setIsReady(false);
setIsWaitingForData(false);
isWaitingForDataRef.current = false;
stopProgressMonitor();
}, [stopProgressMonitor]);
// Set up video event listeners
useEffect(() => {
const video = videoRef.current;
if (!video || !enabled) return;
const onCanPlay = () => {
setIsReady(true);
};
const onWaiting = () => {
const bufferedInfo =
video.buffered.length > 0
? `${video.buffered.start(0).toFixed(2)}-${video.buffered.end(video.buffered.length - 1).toFixed(2)}`
: 'none';
logger.info('useVideoBufferingState: Video waiting for data', {
currentTime: video.currentTime.toFixed(2),
buffered: bufferedInfo,
});
setIsWaitingForData(true);
isWaitingForDataRef.current = true;
};
const onPlaying = () => {
// Clear waiting state when playback resumes
if (isWaitingForDataRef.current) {
logger.info('useVideoBufferingState: Resumed playback after buffering');
setIsWaitingForData(false);
isWaitingForDataRef.current = false;
}
};
const onProgress = () => {
updateProgressTime();
};
video.addEventListener('canplay', onCanPlay);
video.addEventListener('waiting', onWaiting);
video.addEventListener('playing', onPlaying);
video.addEventListener('progress', onProgress);
return () => {
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('waiting', onWaiting);
video.removeEventListener('playing', onPlaying);
video.removeEventListener('progress', onProgress);
stopProgressMonitor();
};
}, [videoRef, enabled, stopProgressMonitor, updateProgressTime]);
// Notify on buffering state change
const isBuffering = !isReady || isWaitingForData;
useEffect(() => {
onBufferingChangeRef.current?.(isBuffering);
}, [isBuffering]);
return {
isBuffering,
isWaitingForData,
isReady,
reset,
startProgressMonitor,
stopProgressMonitor,
updateProgressTime,
};
}
export default useVideoBufferingState;

View File

@ -0,0 +1,174 @@
/**
* useVideoErrorRecovery Hook
*
* Handles video error recovery strategies:
* - Safari decode error retry (error code 3)
* - Proxy URL fallback on presigned URL failure
*/
import { useRef, useCallback, useEffect, type RefObject } from 'react';
import { logger } from '../../lib/logger';
import {
isPresignedUrl,
buildProxyUrl,
markPresignedUrlFailed,
} from '../../lib/assetUrl';
import { TRANSITION_CONFIG } from '../../config/transition.config';
import { isSafari } from '../../lib/browserUtils';
export interface UseVideoErrorRecoveryOptions {
videoRef: RefObject<HTMLVideoElement | null>;
enabled?: boolean;
/** Storage key for proxy URL fallback */
storageKey?: string;
/** Current video source URL */
currentUrl?: string;
/** Max decode retry attempts (default from config) */
maxDecodeRetries?: number;
/** Callback to reload video with new source */
onRetryWithSource?: (newUrl: string) => void;
/** Callback when all recovery attempts fail */
onUnrecoverableError?: (reason: string, error?: MediaError) => void;
}
export interface UseVideoErrorRecoveryResult {
/** Reset error recovery state */
reset: () => void;
/** Manually handle an error (returns true if handled, false if unrecoverable) */
handleError: (video: HTMLVideoElement) => boolean;
}
/**
* Hook for handling video error recovery.
*
* Supports:
* - Safari decode error retry (reloading the video)
* - Proxy URL fallback when presigned URLs fail
*
* @example
* const { reset, handleError } = useVideoErrorRecovery({
* videoRef,
* storageKey: 'assets/video.mp4',
* currentUrl: presignedUrl,
* onRetryWithSource: (newUrl) => {
* video.src = newUrl;
* video.load();
* },
* onUnrecoverableError: (reason) => console.error('Video error:', reason),
* });
*/
export function useVideoErrorRecovery({
videoRef,
enabled = true,
storageKey,
currentUrl,
maxDecodeRetries,
onRetryWithSource,
onUnrecoverableError,
}: UseVideoErrorRecoveryOptions): UseVideoErrorRecoveryResult {
const decodeRetryCountRef = useRef(0);
const didTryProxyRef = useRef(false);
const onRetryWithSourceRef = useRef(onRetryWithSource);
const onUnrecoverableErrorRef = useRef(onUnrecoverableError);
const storageKeyRef = useRef(storageKey);
const currentUrlRef = useRef(currentUrl);
const actualMaxDecodeRetries =
maxDecodeRetries ?? TRANSITION_CONFIG.retry.maxDecodeRetries;
useEffect(() => {
onRetryWithSourceRef.current = onRetryWithSource;
onUnrecoverableErrorRef.current = onUnrecoverableError;
storageKeyRef.current = storageKey;
currentUrlRef.current = currentUrl;
});
const reset = useCallback(() => {
decodeRetryCountRef.current = 0;
didTryProxyRef.current = false;
}, []);
const handleError = useCallback(
(video: HTMLVideoElement): boolean => {
const error = video.error;
if (!error) return false;
const errorCode = error.code;
const errorMessage = (error as MediaError & { message?: string }).message || '';
logger.error('useVideoErrorRecovery: Video error', {
code: errorCode,
message: errorMessage,
currentSrc: video.currentSrc?.slice(-50),
readyState: video.readyState,
networkState: video.networkState,
});
// Safari decode error (error code 3) - try reload
if (
isSafari() &&
errorCode === 3 &&
decodeRetryCountRef.current < actualMaxDecodeRetries
) {
decodeRetryCountRef.current++;
logger.info('useVideoErrorRecovery: Safari decode error, attempting reload', {
attempt: decodeRetryCountRef.current,
maxAttempts: actualMaxDecodeRetries,
});
video.load();
video.play().catch(() => {
/* ignore play errors during recovery */
});
return true;
}
// Network error with presigned URL - try proxy
const currentStorageKey = storageKeyRef.current;
const url = currentUrlRef.current;
if (
currentStorageKey &&
url &&
isPresignedUrl(url) &&
!didTryProxyRef.current
) {
didTryProxyRef.current = true;
logger.info('useVideoErrorRecovery: Presigned URL failed, retrying with proxy', {
storageKey: currentStorageKey.slice(-40),
});
markPresignedUrlFailed(currentStorageKey);
const proxyUrl = buildProxyUrl(currentStorageKey);
onRetryWithSourceRef.current?.(proxyUrl);
return true;
}
// Unrecoverable error
onUnrecoverableErrorRef.current?.('video-error', error);
return false;
},
[actualMaxDecodeRetries],
);
// Set up error event listener
useEffect(() => {
const video = videoRef.current;
if (!video || !enabled) return;
const onError = () => {
handleError(video);
};
video.addEventListener('error', onError);
return () => {
video.removeEventListener('error', onError);
};
}, [videoRef, enabled, handleError]);
return {
reset,
handleError,
};
}
export default useVideoErrorRecovery;

View File

@ -0,0 +1,114 @@
/**
* useVideoEventManager Hook
*
* Manages video element event listener setup and cleanup.
* Provides a declarative API for subscribing to video events.
*/
import { useEffect, type RefObject } from 'react';
export type VideoEventType =
| 'loadedmetadata'
| 'loadeddata'
| 'canplay'
| 'canplaythrough'
| 'playing'
| 'pause'
| 'ended'
| 'timeupdate'
| 'seeking'
| 'seeked'
| 'waiting'
| 'progress'
| 'stalled'
| 'error'
| 'abort';
export type VideoEventHandler = (event: Event) => void;
export interface VideoEventHandlers {
onLoadedMetadata?: VideoEventHandler;
onLoadedData?: VideoEventHandler;
onCanPlay?: VideoEventHandler;
onCanPlayThrough?: VideoEventHandler;
onPlaying?: VideoEventHandler;
onPause?: VideoEventHandler;
onEnded?: VideoEventHandler;
onTimeUpdate?: VideoEventHandler;
onSeeking?: VideoEventHandler;
onSeeked?: VideoEventHandler;
onWaiting?: VideoEventHandler;
onProgress?: VideoEventHandler;
onStalled?: VideoEventHandler;
onError?: VideoEventHandler;
onAbort?: VideoEventHandler;
}
export interface UseVideoEventManagerOptions {
videoRef: RefObject<HTMLVideoElement | null>;
enabled?: boolean;
handlers: VideoEventHandlers;
}
const EVENT_MAP: Record<keyof VideoEventHandlers, VideoEventType> = {
onLoadedMetadata: 'loadedmetadata',
onLoadedData: 'loadeddata',
onCanPlay: 'canplay',
onCanPlayThrough: 'canplaythrough',
onPlaying: 'playing',
onPause: 'pause',
onEnded: 'ended',
onTimeUpdate: 'timeupdate',
onSeeking: 'seeking',
onSeeked: 'seeked',
onWaiting: 'waiting',
onProgress: 'progress',
onStalled: 'stalled',
onError: 'error',
onAbort: 'abort',
};
/**
* Hook for managing video element event listeners.
*
* @example
* useVideoEventManager({
* videoRef,
* enabled: true,
* handlers: {
* onPlaying: () => console.log('Video started playing'),
* onWaiting: () => console.log('Video is buffering'),
* onEnded: () => console.log('Video ended'),
* },
* });
*/
export function useVideoEventManager({
videoRef,
enabled = true,
handlers,
}: UseVideoEventManagerOptions): void {
useEffect(() => {
const video = videoRef.current;
if (!video || !enabled) return;
const boundHandlers: Array<[VideoEventType, VideoEventHandler]> = [];
// Set up event listeners
for (const [handlerKey, eventType] of Object.entries(EVENT_MAP)) {
const handler = handlers[handlerKey as keyof VideoEventHandlers];
if (handler) {
video.addEventListener(eventType, handler);
boundHandlers.push([eventType, handler]);
}
}
// Cleanup
return () => {
for (const [eventType, handler] of boundHandlers) {
video.removeEventListener(eventType, handler);
}
};
}, [videoRef, enabled, handlers]);
}
export default useVideoEventManager;

View File

@ -0,0 +1,123 @@
/**
* useVideoFirstFrame Hook
*
* Detects when the first video frame is painted using:
* - requestVideoFrameCallback (modern browsers, Safari 15.4+)
* - requestAnimationFrame fallback (older browsers)
*/
import { useState, useRef, useCallback, useEffect, type RefObject } from 'react';
import { logger } from '../../lib/logger';
import { scheduleAfterPaint } from '../../lib/browserUtils';
export interface UseVideoFirstFrameOptions {
videoRef: RefObject<HTMLVideoElement | null>;
enabled?: boolean;
/** Callback when first frame is painted */
onFirstFrame?: () => void;
}
export interface UseVideoFirstFrameResult {
/** True when first frame has been painted */
isFirstFramePainted: boolean;
/** Reset first frame state */
reset: () => void;
}
/**
* Hook for detecting when the first video frame is painted.
*
* Uses requestVideoFrameCallback when available (Safari 15.4+, Chrome, Firefox)
* for precise frame-level detection. Falls back to requestAnimationFrame
* with scheduleAfterPaint for older browsers.
*
* @example
* const { isFirstFramePainted, reset } = useVideoFirstFrame({
* videoRef,
* enabled: true,
* onFirstFrame: () => console.log('First frame painted'),
* });
*/
export function useVideoFirstFrame({
videoRef,
enabled = true,
onFirstFrame,
}: UseVideoFirstFrameOptions): UseVideoFirstFrameResult {
const [isFirstFramePainted, setIsFirstFramePainted] = useState(false);
const callbackIdRef = useRef<number | null>(null);
const onFirstFrameRef = useRef(onFirstFrame);
const didFireRef = useRef(false);
useEffect(() => {
onFirstFrameRef.current = onFirstFrame;
});
const reset = useCallback(() => {
setIsFirstFramePainted(false);
didFireRef.current = false;
// Cancel pending callback
if (callbackIdRef.current !== null) {
const video = videoRef.current;
if (video && 'cancelVideoFrameCallback' in video) {
(video as HTMLVideoElement & { cancelVideoFrameCallback: (id: number) => void })
.cancelVideoFrameCallback(callbackIdRef.current);
}
callbackIdRef.current = null;
}
}, [videoRef]);
// Set up first frame detection when video starts playing
useEffect(() => {
const video = videoRef.current;
if (!video || !enabled) return;
const handlePlaying = () => {
if (didFireRef.current) return;
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+)
if ('requestVideoFrameCallback' in video) {
const rvfc = (video as HTMLVideoElement & {
requestVideoFrameCallback: (
callback: (now: number, metadata: VideoFrameCallbackMetadata) => void
) => number;
}).requestVideoFrameCallback.bind(video);
// First callback: frame is composited, safe to show overlay
callbackIdRef.current = rvfc((_now, _metadata) => {
if (!didFireRef.current) {
didFireRef.current = true;
setIsFirstFramePainted(true);
logger.info('useVideoFirstFrame: First frame painted (rvfc)');
onFirstFrameRef.current?.();
}
callbackIdRef.current = null;
});
} else {
// Fallback for older browsers without requestVideoFrameCallback
scheduleAfterPaint(() => {
if (!didFireRef.current) {
didFireRef.current = true;
setIsFirstFramePainted(true);
logger.info('useVideoFirstFrame: First frame painted (rAF fallback)');
onFirstFrameRef.current?.();
}
});
}
};
video.addEventListener('playing', handlePlaying);
return () => {
video.removeEventListener('playing', handlePlaying);
reset();
};
}, [videoRef, enabled, reset]);
return {
isFirstFramePainted,
reset,
};
}
export default useVideoFirstFrame;

View File

@ -0,0 +1,337 @@
/**
* useVideoPlaybackCore Hook
*
* Composite hook that combines all video primitives into a unified
* playback controller. Used as the foundation for both transition
* and background video playback.
*/
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
import { logger } from '../../lib/logger';
import { useVideoBlobUrl, type PreloadCacheProvider } from './useVideoBlobUrl';
import { useVideoBufferingState } from './useVideoBufferingState';
import { useVideoFirstFrame } from './useVideoFirstFrame';
import { useVideoErrorRecovery } from './useVideoErrorRecovery';
import { useVideoTimeouts } from './useVideoTimeouts';
export interface UseVideoPlaybackCoreOptions {
videoRef: RefObject<HTMLVideoElement | null>;
/** Source URL or storage path */
sourceUrl?: string;
/** Storage key for cache lookup */
storageKey?: string;
/** Preload cache provider */
preloadCache?: PreloadCacheProvider;
/** Whether to autoplay when ready */
autoplay?: boolean;
/** Whether video is muted */
muted?: boolean;
/** External pause control */
paused?: boolean;
/** Timeout for playback start (ms) */
playbackStartTimeoutMs?: number;
/** Callback when video is ready (first frame painted) */
onReady?: () => void;
/** Callback on error */
onError?: (reason: string) => void;
/** Callback when buffering state changes */
onBufferingChange?: (isBuffering: boolean) => void;
/** Callback when video ends */
onEnded?: () => void;
}
export interface UseVideoPlaybackCoreResult {
/** True when first frame has been painted */
isReady: boolean;
/** True during buffering (initial load or mid-playback) */
isBuffering: boolean;
/** True specifically when waiting for network data mid-playback */
isWaitingForData: boolean;
/** Resolved playable URL */
resolvedUrl: string | null;
/** Whether URL resolution is in progress */
isResolving: boolean;
/** Start playback */
play: () => Promise<void>;
/** Pause playback */
pause: () => void;
/** Reset all state */
reset: () => void;
}
const DEFAULT_PLAYBACK_START_TIMEOUT_MS = 3000;
/**
* Core video playback hook that composes all video primitives.
*
* Provides unified handling for:
* - Multi-tier URL resolution (blob, cached, streaming, proxy)
* - Buffering state detection (initial and mid-playback)
* - First frame detection (rvfc with fallback)
* - Error recovery (Safari decode error, proxy fallback)
* - Timer management
*
* @example
* const {
* isReady,
* isBuffering,
* resolvedUrl,
* play,
* pause,
* } = useVideoPlaybackCore({
* videoRef,
* sourceUrl: 'assets/video.mp4',
* storageKey: 'assets/video.mp4',
* autoplay: true,
* onReady: () => console.log('Video ready'),
* onError: (reason) => console.error('Error:', reason),
* });
*/
export function useVideoPlaybackCore({
videoRef,
sourceUrl,
storageKey,
preloadCache,
autoplay = false,
muted = true,
paused = false,
playbackStartTimeoutMs = DEFAULT_PLAYBACK_START_TIMEOUT_MS,
onReady,
onError,
onBufferingChange,
onEnded,
}: UseVideoPlaybackCoreOptions): UseVideoPlaybackCoreResult {
const [isSourceLoaded, setIsSourceLoaded] = useState(false);
const didStartPlaybackRef = useRef(false);
const currentSourceRef = useRef<string | null>(null);
const onReadyRef = useRef(onReady);
const onErrorRef = useRef(onError);
const onBufferingChangeRef = useRef(onBufferingChange);
const onEndedRef = useRef(onEnded);
useEffect(() => {
onReadyRef.current = onReady;
onErrorRef.current = onError;
onBufferingChangeRef.current = onBufferingChange;
onEndedRef.current = onEnded;
});
// Timer management
const { setTimer, clearTimer, clearAllTimers } = useVideoTimeouts();
// URL resolution
const {
resolvedUrl,
isResolving,
revoke: revokeBlobUrl,
} = useVideoBlobUrl({
sourceUrl: sourceUrl || '',
storageKey,
preloadCache,
onError: (error) => {
logger.error('useVideoPlaybackCore: URL resolution failed', { error });
onErrorRef.current?.('source-resolution-failed');
},
});
// Buffering state
const {
isBuffering: isBufferingFromState,
isWaitingForData,
isReady: isBufferReady,
reset: resetBufferingState,
startProgressMonitor,
} = useVideoBufferingState({
videoRef,
enabled: Boolean(sourceUrl),
onBufferingChange: (buffering) => {
onBufferingChangeRef.current?.(buffering);
},
onProgressTimeout: () => {
onErrorRef.current?.('no-progress-timeout');
},
});
// First frame detection
const { isFirstFramePainted, reset: resetFirstFrame } = useVideoFirstFrame({
videoRef,
enabled: Boolean(sourceUrl),
onFirstFrame: () => {
logger.info('useVideoPlaybackCore: First frame painted');
onReadyRef.current?.();
},
});
// Error recovery
const { reset: resetErrorRecovery } = useVideoErrorRecovery({
videoRef,
enabled: Boolean(sourceUrl),
storageKey,
currentUrl: resolvedUrl || undefined,
onRetryWithSource: (newUrl) => {
const video = videoRef.current;
if (video) {
video.src = newUrl;
video.load();
video.play().catch(() => {
/* ignore play errors during recovery */
});
}
},
onUnrecoverableError: (reason) => {
onErrorRef.current?.(reason);
},
});
// Combined ready state
const isReady = isFirstFramePainted && isBufferReady;
const isBuffering = !isReady || isBufferingFromState || isWaitingForData;
// Play function
const play = useCallback(async () => {
const video = videoRef.current;
if (!video) return;
try {
await video.play();
didStartPlaybackRef.current = true;
clearTimer('playbackStart');
} catch (playError) {
logger.warn('useVideoPlaybackCore: Play failed', { playError });
}
}, [videoRef, clearTimer]);
// Pause function
const pause = useCallback(() => {
const video = videoRef.current;
if (video) {
video.pause();
}
}, [videoRef]);
// Reset function
const reset = useCallback(() => {
clearAllTimers();
resetBufferingState();
resetFirstFrame();
resetErrorRecovery();
revokeBlobUrl();
setIsSourceLoaded(false);
didStartPlaybackRef.current = false;
currentSourceRef.current = null;
}, [
clearAllTimers,
resetBufferingState,
resetFirstFrame,
resetErrorRecovery,
revokeBlobUrl,
]);
// Load and play when URL is resolved
useEffect(() => {
const video = videoRef.current;
if (!video || !resolvedUrl || isResolving) return;
// Skip if already loaded this source
if (currentSourceRef.current === resolvedUrl && isSourceLoaded) return;
logger.info('useVideoPlaybackCore: Loading video', {
url: resolvedUrl.slice(-50),
autoplay,
paused,
});
currentSourceRef.current = resolvedUrl;
setIsSourceLoaded(false);
didStartPlaybackRef.current = false;
// Set video source
video.src = resolvedUrl;
video.muted = muted;
video.load();
// Start progress monitoring for streaming
startProgressMonitor();
setIsSourceLoaded(true);
// Autoplay if not externally paused
if (autoplay && !paused) {
play();
// Set playback start watchdog
setTimer(
'playbackStart',
() => {
if (!didStartPlaybackRef.current) {
logger.warn('useVideoPlaybackCore: Playback start timeout, retrying');
play();
}
},
playbackStartTimeoutMs,
);
}
}, [
videoRef,
resolvedUrl,
isResolving,
autoplay,
paused,
muted,
play,
setTimer,
startProgressMonitor,
playbackStartTimeoutMs,
isSourceLoaded,
]);
// Handle external pause control
useEffect(() => {
const video = videoRef.current;
if (!video || !isSourceLoaded) return;
if (paused) {
video.pause();
} else if (autoplay && !video.paused) {
// Already playing, do nothing
} else if (autoplay) {
play();
}
}, [videoRef, paused, autoplay, play, isSourceLoaded]);
// Handle video ended
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleEnded = () => {
onEndedRef.current?.();
};
video.addEventListener('ended', handleEnded);
return () => {
video.removeEventListener('ended', handleEnded);
};
}, [videoRef]);
// Cleanup on unmount or source change
useEffect(() => {
return () => {
reset();
};
}, [sourceUrl, reset]);
return {
isReady,
isBuffering,
isWaitingForData,
resolvedUrl,
isResolving,
play,
pause,
reset,
};
}
export default useVideoPlaybackCore;

View File

@ -0,0 +1,245 @@
/**
* useVideoPlayer Hook
*
* Video player hook for UI elements (VideoPlayerElement).
* Built on video primitives for consistent behavior:
* - Multi-tier URL resolution (blob cached presigned proxy)
* - Safari decode error recovery
* - Optional buffering state tracking
*
* Designed for video player UI elements embedded in pages,
* not for transition or background videos which have their own hooks.
*/
import { useRef, useCallback, useEffect, useState, type RefObject } from 'react';
import { logger } from '../../lib/logger';
import { useVideoBlobUrl, type PreloadCacheProvider } from './useVideoBlobUrl';
import { useVideoErrorRecovery } from './useVideoErrorRecovery';
export interface UseVideoPlayerOptions {
/** Source URL or storage path */
sourceUrl?: string;
/** Storage key for cache lookup (defaults to sourceUrl) */
storageKey?: string;
/** Preload cache provider for blob URL resolution */
preloadCache?: PreloadCacheProvider;
/** Whether to autoplay */
autoplay?: boolean;
/** Whether to loop */
loop?: boolean;
/** Whether to mute */
muted?: boolean;
/** Track buffering state for loading indicator (default: false) */
trackBuffering?: boolean;
/** Callback when video starts playing */
onPlay?: () => void;
/** Callback when video pauses */
onPause?: () => void;
/** Callback when video ends */
onEnded?: () => void;
/** Callback on error */
onError?: (reason: string) => void;
}
export interface UseVideoPlayerResult {
/** Ref to attach to video element */
videoRef: RefObject<HTMLVideoElement | null>;
/** Resolved playable URL */
resolvedUrl: string | null;
/** Whether URL is being resolved */
isResolving: boolean;
/** Whether video is buffering (only tracked if trackBuffering=true) */
isBuffering: boolean;
/** Whether video has loaded and can play */
isReady: boolean;
/** Play the video */
play: () => Promise<void>;
/** Pause the video */
pause: () => void;
}
/**
* Hook for video player UI elements.
*
* Provides unified video handling with:
* - Multi-tier URL resolution via preload cache
* - Safari decode error recovery
* - Optional buffering state tracking
*
* @example
* const {
* videoRef,
* resolvedUrl,
* isBuffering,
* isReady,
* } = useVideoPlayer({
* sourceUrl: element.mediaUrl,
* preloadCache,
* autoplay: element.mediaAutoplay,
* loop: element.mediaLoop,
* muted: element.mediaMuted,
* trackBuffering: true,
* });
*/
export function useVideoPlayer({
sourceUrl,
storageKey,
preloadCache,
autoplay = false,
loop = false,
muted = true,
trackBuffering = false,
onPlay,
onPause,
onEnded,
onError,
}: UseVideoPlayerOptions): UseVideoPlayerResult {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [isBuffering, setIsBuffering] = useState(false);
const [isReady, setIsReady] = useState(false);
// Refs for callbacks
const onPlayRef = useRef(onPlay);
const onPauseRef = useRef(onPause);
const onEndedRef = useRef(onEnded);
const onErrorRef = useRef(onError);
useEffect(() => {
onPlayRef.current = onPlay;
onPauseRef.current = onPause;
onEndedRef.current = onEnded;
onErrorRef.current = onError;
});
// URL resolution via video primitives
const {
resolvedUrl,
isResolving,
revoke: revokeBlobUrl,
} = useVideoBlobUrl({
sourceUrl: sourceUrl || '',
storageKey: storageKey || sourceUrl,
preloadCache,
onError: (error) => {
logger.error('useVideoPlayer: URL resolution failed', { error });
onErrorRef.current?.('source-resolution-failed');
},
});
// Error recovery (Safari decode errors, proxy fallback)
const { reset: resetErrorRecovery } = useVideoErrorRecovery({
videoRef,
enabled: Boolean(sourceUrl),
storageKey: storageKey || sourceUrl,
currentUrl: resolvedUrl || undefined,
onRetryWithSource: (newUrl) => {
const video = videoRef.current;
if (video) {
video.src = newUrl;
video.load();
if (autoplay) {
video.play().catch(() => {
/* ignore play errors during recovery */
});
}
}
},
onUnrecoverableError: (reason) => {
onErrorRef.current?.(reason);
},
});
// Play function
const play = useCallback(async () => {
const video = videoRef.current;
if (!video) return;
try {
await video.play();
} catch (playError) {
logger.warn('useVideoPlayer: Play failed', { playError });
}
}, []);
// Pause function
const pause = useCallback(() => {
const video = videoRef.current;
if (video) {
video.pause();
}
}, []);
// Event handlers
useEffect(() => {
const video = videoRef.current;
if (!video || !resolvedUrl) return;
const handleCanPlay = () => {
setIsReady(true);
if (trackBuffering) {
setIsBuffering(false);
}
};
const handleWaiting = () => {
if (trackBuffering) {
setIsBuffering(true);
}
};
const handlePlaying = () => {
if (trackBuffering) {
setIsBuffering(false);
}
onPlayRef.current?.();
};
const handlePause = () => {
onPauseRef.current?.();
};
const handleEnded = () => {
onEndedRef.current?.();
};
video.addEventListener('canplay', handleCanPlay);
video.addEventListener('waiting', handleWaiting);
video.addEventListener('playing', handlePlaying);
video.addEventListener('pause', handlePause);
video.addEventListener('ended', handleEnded);
return () => {
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('waiting', handleWaiting);
video.removeEventListener('playing', handlePlaying);
video.removeEventListener('pause', handlePause);
video.removeEventListener('ended', handleEnded);
};
}, [resolvedUrl, trackBuffering]);
// Reset state when source changes
useEffect(() => {
setIsReady(false);
setIsBuffering(false);
resetErrorRecovery();
}, [sourceUrl, resetErrorRecovery]);
// Cleanup on unmount
useEffect(() => {
return () => {
revokeBlobUrl();
};
}, [revokeBlobUrl]);
return {
videoRef,
resolvedUrl,
isResolving,
isBuffering,
isReady,
play,
pause,
};
}
export default useVideoPlayer;

View File

@ -0,0 +1,93 @@
/**
* useVideoTimeouts Hook
*
* Manages video-related timers including:
* - Playback start watchdog
* - Finish timer (duration-based)
* - Custom timers
*/
import { useRef, useCallback, useEffect } from 'react';
export interface UseVideoTimeoutsResult {
/** Set a named timer */
setTimer: (name: string, callback: () => void, delayMs: number) => void;
/** Clear a named timer */
clearTimer: (name: string) => void;
/** Clear all timers */
clearAllTimers: () => void;
/** Check if a timer is active */
isTimerActive: (name: string) => boolean;
}
/**
* Hook for managing video-related timers.
*
* Provides a clean API for setting, clearing, and tracking
* multiple named timers. All timers are automatically cleaned
* up on unmount.
*
* @example
* const { setTimer, clearTimer, clearAllTimers } = useVideoTimeouts();
*
* // Start playback watchdog
* setTimer('watchdog', () => {
* console.log('Playback did not start in time');
* }, 3000);
*
* // Clear when playback starts
* clearTimer('watchdog');
*/
export function useVideoTimeouts(): UseVideoTimeoutsResult {
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const clearTimer = useCallback((name: string) => {
const timer = timersRef.current.get(name);
if (timer) {
clearTimeout(timer);
timersRef.current.delete(name);
}
}, []);
const clearAllTimers = useCallback(() => {
timersRef.current.forEach((timer) => {
clearTimeout(timer);
});
timersRef.current.clear();
}, []);
const setTimer = useCallback(
(name: string, callback: () => void, delayMs: number) => {
// Clear existing timer with same name
clearTimer(name);
const timer = setTimeout(() => {
timersRef.current.delete(name);
callback();
}, delayMs);
timersRef.current.set(name, timer);
},
[clearTimer],
);
const isTimerActive = useCallback((name: string) => {
return timersRef.current.has(name);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
clearAllTimers();
};
}, [clearAllTimers]);
return {
setTimer,
clearTimer,
clearAllTimers,
isTimerActive,
};
}
export default useVideoTimeouts;

View File

@ -229,22 +229,8 @@ export class AssetCacheService {
if (!downloadUrl) continue; if (!downloadUrl) continue;
// Determine download parameters based on mode // Always full downloads with blob URLs for reliable playback
const isOnlineMode = mode === 'online'; // (presigned URL streaming fails on mobile Safari/Chrome)
const isCurrentPageAsset =
currentPageId && asset.pageId === currentPageId;
// Online mode: use partial downloads for neighbor page media
// Offline mode: always full downloads
const maxBytes = this.getMaxBytesForAsset(
asset.assetType,
isOnlineMode,
!isCurrentPageAsset,
);
// Create blob URL for images (instant navigation) and full downloads
const createBlobUrl =
asset.assetType === 'image' || maxBytes === undefined;
try { try {
await downloadManager await downloadManager
@ -257,9 +243,7 @@ export class AssetCacheService {
assetType: asset.assetType, assetType: asset.assetType,
priority: asset.priority, priority: asset.priority,
storageKey: asset.storageKey, storageKey: asset.storageKey,
createBlobUrl,
persist: mode === 'offline', // Only persist for offline mode persist: mode === 'offline', // Only persist for offline mode
maxBytes,
}) })
.then(() => { .then(() => {
if (isPresignedUrl(downloadUrl)) { if (isPresignedUrl(downloadUrl)) {
@ -287,9 +271,7 @@ export class AssetCacheService {
assetType: asset.assetType, assetType: asset.assetType,
priority: asset.priority, priority: asset.priority,
storageKey: asset.storageKey, storageKey: asset.storageKey,
createBlobUrl,
persist: mode === 'offline', persist: mode === 'offline',
maxBytes,
}); });
} catch { } catch {
// Ignore retry failures // Ignore retry failures
@ -311,39 +293,6 @@ export class AssetCacheService {
}); });
} }
/**
* Determine max bytes for partial preload (online mode only)
*/
private static getMaxBytesForAsset(
assetType: AssetType,
isOnlineMode: boolean,
isNeighborPage: boolean,
): number | undefined {
// Offline mode: always full downloads
if (!isOnlineMode) return undefined;
// Partial preload disabled
if (!PRELOAD_CONFIG.partialPreload.enabled) return undefined;
// Transitions always use partial preload
if (assetType === 'transition') {
return PRELOAD_CONFIG.partialPreload.transitionMaxBytes;
}
// Current page assets should be fully downloaded
if (!isNeighborPage) return undefined;
// Neighbor page media uses partial preload
switch (assetType) {
case 'video':
return PRELOAD_CONFIG.partialPreload.videoMaxBytes;
case 'audio':
return PRELOAD_CONFIG.partialPreload.audioMaxBytes;
default:
return undefined; // Images need full download for display
}
}
/** /**
* Clear project cache * Clear project cache
*/ */

View File

@ -167,12 +167,19 @@ export const scheduleAfterPaint = (callback: () => void): void => {
}; };
/** /**
* Get crossfade duration from CSS custom property. * Get crossfade duration from CSS custom property or override.
* Single source of truth: CSS variable --crossfade-duration in main.css. * Single source of truth: CSS variable --crossfade-duration in main.css,
* unless an explicit override is provided.
* *
* @returns Duration in milliseconds (default: 500ms) * @param overrideMs - Optional explicit duration in milliseconds (for hierarchical transition settings)
* @returns Duration in milliseconds (default: 700ms)
*/ */
export const getCrossfadeDuration = (): number => { export const getCrossfadeDuration = (overrideMs?: number): number => {
// If explicit override provided, use it
if (overrideMs !== undefined && overrideMs >= 0) {
return overrideMs;
}
if (typeof window === 'undefined') return 700; // SSR fallback if (typeof window === 'undefined') return 700; // SSR fallback
const root = document.documentElement; const root = document.documentElement;

View File

@ -210,8 +210,8 @@ export const createDefaultElement = (
id: createLocalId(), id: createLocalId(),
type, type,
label: ELEMENT_TYPE_LABELS[type] || type, label: ELEMENT_TYPE_LABELS[type] || type,
xPercent: clamp(12 + index * 4, 5, 80), xPercent: clamp(45 + index * 3, 10, 85), // Center horizontally
yPercent: clamp(16 + index * 6, 8, 85), yPercent: clamp(45 + index * 4, 15, 80), // Center vertically
appearDelaySec: 0, appearDelaySec: 0,
appearDurationSec: null, appearDurationSec: null,
}; };

View File

@ -161,16 +161,26 @@ export function extractPageLinksAndElements(
} }
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) { if (resolvedTargetPageId && resolvedTargetPageId !== page.id) {
const hasTransitionVideo =
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string';
const hasReverseVideo =
el.reverseVideoUrl && typeof el.reverseVideoUrl === 'string';
pageLinks.push({ pageLinks.push({
id: `synthetic-${page.id}-${el.id || preloadElements.length}`, id: `synthetic-${page.id}-${el.id || preloadElements.length}`,
from_pageId: page.id, from_pageId: page.id,
to_pageId: resolvedTargetPageId, to_pageId: resolvedTargetPageId,
is_active: true, is_active: true,
transition: transition:
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string' hasTransitionVideo || hasReverseVideo
? { ? {
id: `transition-${el.id || preloadElements.length}`, id: `transition-${el.id || preloadElements.length}`,
video_url: el.transitionVideoUrl, video_url: hasTransitionVideo
? (el.transitionVideoUrl as string)
: undefined,
reverse_video_url: hasReverseVideo
? (el.reverseVideoUrl as string)
: undefined,
} }
: undefined, : undefined,
}); });
@ -237,16 +247,26 @@ export function extractPageLinksOnly(
} }
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) { if (resolvedTargetPageId && resolvedTargetPageId !== page.id) {
const hasTransitionVideo =
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string';
const hasReverseVideo =
el.reverseVideoUrl && typeof el.reverseVideoUrl === 'string';
pageLinks.push({ pageLinks.push({
id: `synthetic-${page.id}-${el.id || Math.random().toString(36).slice(2)}`, id: `synthetic-${page.id}-${el.id || Math.random().toString(36).slice(2)}`,
from_pageId: page.id, from_pageId: page.id,
to_pageId: resolvedTargetPageId, to_pageId: resolvedTargetPageId,
is_active: true, is_active: true,
transition: transition:
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string' hasTransitionVideo || hasReverseVideo
? { ? {
id: `transition-${el.id || Math.random().toString(36).slice(2)}`, id: `transition-${el.id || Math.random().toString(36).slice(2)}`,
video_url: el.transitionVideoUrl, video_url: hasTransitionVideo
? (el.transitionVideoUrl as string)
: undefined,
reverse_video_url: hasReverseVideo
? (el.reverseVideoUrl as string)
: undefined,
} }
: undefined, : undefined,
}); });

View File

@ -20,6 +20,11 @@ export interface FontOption {
* Supported fonts for UI elements * Supported fonts for UI elements
*/ */
export const FONT_OPTIONS: FontOption[] = [ export const FONT_OPTIONS: FontOption[] = [
{
key: 'maple-medium',
label: 'Maple Medium',
fontFamily: 'Maple',
},
{ {
key: 'instrument-sans', key: 'instrument-sans',
label: 'Instrument Sans', label: 'Instrument Sans',

View File

@ -15,6 +15,7 @@ import type {
ProjectDownloadProgressEvent, ProjectDownloadProgressEvent,
ProjectDownloadCompleteEvent, ProjectDownloadCompleteEvent,
BlobUrlReadyEvent, BlobUrlReadyEvent,
StreamingReadyEvent,
} from '../../types/offline'; } from '../../types/offline';
type EventMap = { type EventMap = {
@ -26,6 +27,7 @@ type EventMap = {
[OFFLINE_CONFIG.events.projectDownloadComplete]: ProjectDownloadCompleteEvent; [OFFLINE_CONFIG.events.projectDownloadComplete]: ProjectDownloadCompleteEvent;
[OFFLINE_CONFIG.events.queueUpdate]: void; [OFFLINE_CONFIG.events.queueUpdate]: void;
[OFFLINE_CONFIG.events.blobUrlReady]: BlobUrlReadyEvent; [OFFLINE_CONFIG.events.blobUrlReady]: BlobUrlReadyEvent;
[OFFLINE_CONFIG.events.streamingReady]: StreamingReadyEvent;
}; };
type EventCallback<T> = (data: T) => void; type EventCallback<T> = (data: T) => void;
@ -182,6 +184,13 @@ class DownloadEventBusClass {
emitBlobUrlReady(data: BlobUrlReadyEvent): void { emitBlobUrlReady(data: BlobUrlReadyEvent): void {
this.emit(OFFLINE_CONFIG.events.blobUrlReady as keyof EventMap, data); this.emit(OFFLINE_CONFIG.events.blobUrlReady as keyof EventMap, data);
} }
/**
* Emit streaming ready event (minimum buffer downloaded for playback)
*/
emitStreamingReady(data: StreamingReadyEvent): void {
this.emit(OFFLINE_CONFIG.events.streamingReady as keyof EventMap, data);
}
} }
// Singleton instance // Singleton instance

View File

@ -40,14 +40,19 @@ interface DownloadJob {
retryCount: number; retryCount: number;
addedAt: number; addedAt: number;
storageKey: string; // Canonical storage key for consistent caching storageKey: string; // Canonical storage key for consistent caching
createBlobUrl?: boolean; // Create decoded blob URL after download
persist?: boolean; // Persist to IndexedDB for resume (default: true) persist?: boolean; // Persist to IndexedDB for resume (default: true)
maxBytes?: number; // Partial download limit (undefined = full download)
isPartial?: boolean; // Whether this was a partial download (for tracking)
usedProxyFallback?: boolean; // Whether we've already tried proxy URL fallback usedProxyFallback?: boolean; // Whether we've already tried proxy URL fallback
abortController?: AbortController; abortController?: AbortController;
resolve?: () => void; resolve?: () => void;
reject?: (error: Error) => void; reject?: (error: Error) => void;
/** Streaming mode state */
streamingMode?: {
enabled: boolean;
minBufferBytes: number;
streamingUrl: string;
didSignalReady: boolean;
};
} }
class DownloadManagerClass { class DownloadManagerClass {
@ -59,9 +64,9 @@ class DownloadManagerClass {
// Blob URL cache for instant lookup (storageKey → blobUrl) // Blob URL cache for instant lookup (storageKey → blobUrl)
private readyBlobUrls: Map<string, string> = new Map(); private readyBlobUrls: Map<string, string> = new Map();
// Track partial downloads completed in this session (not persisted) // Raw Blob cache for creating fresh blob URLs (storageKey → Blob)
// Prevents re-downloading same partial content on repeated page visits // Used by transitions to avoid decoder state issues with pre-created blob URLs
private partialDownloadsReady: Set<string> = new Set(); private readyBlobs: Map<string, Blob> = new Map();
private config = { private config = {
maxConcurrent: PRELOAD_CONFIG.maxConcurrentDownloads, maxConcurrent: PRELOAD_CONFIG.maxConcurrentDownloads,
@ -73,6 +78,7 @@ class DownloadManagerClass {
/** /**
* Add a download job to the queue * Add a download job to the queue
* Always creates blob URLs for reliable playback on all devices
*/ */
async addJob(params: { async addJob(params: {
assetId: string; assetId: string;
@ -83,50 +89,26 @@ class DownloadManagerClass {
assetType: AssetType; assetType: AssetType;
priority?: number; priority?: number;
storageKey?: string; // Optional, will extract if not provided storageKey?: string; // Optional, will extract if not provided
createBlobUrl?: boolean; // Create blob URL after download
persist?: boolean; // Persist to IndexedDB for resume (default: true) persist?: boolean; // Persist to IndexedDB for resume (default: true)
maxBytes?: number; // Download limit in bytes (for partial preload) /** Enable streaming mode - signal ready after minimum buffer downloaded */
streamingMode?: {
enabled: boolean;
minBufferBytes?: number;
};
}): Promise<void> { }): Promise<void> {
const storageKey = params.storageKey || extractStoragePath(params.url); const storageKey = params.storageKey || extractStoragePath(params.url);
const isPartialDownload = params.maxBytes !== undefined;
// For partial downloads, check session cache first (fast path) // Check cache status - if fully cached, create blob URL and return
if (isPartialDownload && this.partialDownloadsReady.has(storageKey)) {
logger.info(
'[DownloadManager] Partial download already ready (session)',
{
storageKey: storageKey.slice(-50),
},
);
return;
}
// Check cache status using getAssetInfo for smart handling
const assetInfo = await StorageManager.getAssetInfo(storageKey); const assetInfo = await StorageManager.getAssetInfo(storageKey);
if (assetInfo?.exists) { if (assetInfo?.exists && !assetInfo.isPartial) {
if (isPartialDownload) { // Fully cached - create blob URL if not already ready
// For partial downloads, any cached version is sufficient if (!this.readyBlobUrls.has(storageKey)) {
this.partialDownloadsReady.add(storageKey); await this.createBlobUrlFromCache(storageKey);
return;
} }
return;
if (!assetInfo.isPartial) {
// Fully cached - create blob URL if requested
if (params.createBlobUrl && !this.readyBlobUrls.has(storageKey)) {
await this.createBlobUrlFromCache(storageKey);
}
return;
}
// Asset exists but is partial - full download requested
// NOTE: Don't log here - will log after deduplication check below
} }
// Track if this is an upgrade request (for logging after dedup check)
const isUpgradingPartial =
assetInfo?.exists && assetInfo.isPartial && !isPartialDownload;
// Check if already in queue (use storageKey for deduplication) // Check if already in queue (use storageKey for deduplication)
if ( if (
this.queue.some((j) => j.storageKey === storageKey) || this.queue.some((j) => j.storageKey === storageKey) ||
@ -134,27 +116,10 @@ class DownloadManagerClass {
(j) => j.storageKey === storageKey, (j) => j.storageKey === storageKey,
) )
) { ) {
return; // Already queued - no log needed return; // Already queued
}
// Now we know this job will actually be added - safe to log
if (isUpgradingPartial) {
logger.info('[DownloadManager] Upgrading partial to full download', {
storageKey: storageKey.slice(-50),
});
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// For partial downloads, don't persist and don't create blob URL
// (video will play from presigned URL, browser handles buffering)
const isPartialDownload = params.maxBytes !== undefined;
const shouldPersist = isPartialDownload
? false
: (params.persist ?? true);
const shouldCreateBlobUrl = isPartialDownload
? false
: (params.createBlobUrl ?? false);
const job: DownloadJob = { const job: DownloadJob = {
id: `dl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, id: `dl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
assetId: params.assetId, assetId: params.assetId,
@ -173,12 +138,19 @@ class DownloadManagerClass {
retryCount: 0, retryCount: 0,
addedAt: Date.now(), addedAt: Date.now(),
storageKey, storageKey,
createBlobUrl: shouldCreateBlobUrl, persist: params.persist ?? true,
persist: shouldPersist,
maxBytes: params.maxBytes,
isPartial: isPartialDownload,
resolve, resolve,
reject, reject,
// Initialize streaming mode if enabled
streamingMode: params.streamingMode?.enabled
? {
enabled: true,
minBufferBytes:
params.streamingMode.minBufferBytes ?? this.getMinBufferBytes(),
streamingUrl: params.url,
didSignalReady: false,
}
: undefined,
}; };
// Persist to IndexedDB for resume capability (default true) // Persist to IndexedDB for resume capability (default true)
@ -274,39 +246,23 @@ class DownloadManagerClass {
}); });
try { try {
// Build request headers - use Range header for partial downloads
const headers: HeadersInit = {};
if (job.maxBytes) {
headers['Range'] = `bytes=0-${job.maxBytes - 1}`;
logger.info('[DownloadManager] Partial download requested', {
url: job.url.slice(-50),
maxBytes: job.maxBytes,
});
}
const response = await fetch(job.url, { const response = await fetch(job.url, {
signal: job.abortController.signal, signal: job.abortController.signal,
headers,
}); });
// Accept both 200 OK and 206 Partial Content if (!response.ok) {
if (!response.ok && response.status !== 206) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`); throw new Error(`HTTP ${response.status}: ${response.statusText}`);
} }
const contentLength = response.headers.get('content-length'); const contentLength = response.headers.get('content-length');
job.totalBytes = contentLength ? parseInt(contentLength, 10) : 0; job.totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
// For partial downloads, track if we reached the limit
const isPartialResponse = response.status === 206 || job.maxBytes;
let blob: Blob; let blob: Blob;
if (response.body) { if (response.body) {
// Stream with progress tracking // Stream with progress tracking
const reader = response.body.getReader(); const reader = response.body.getReader();
const chunks: BlobPart[] = []; const chunks: BlobPart[] = [];
let reachedLimit = false;
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@ -315,22 +271,6 @@ class DownloadManagerClass {
chunks.push(value); chunks.push(value);
job.bytesLoaded += value.length; job.bytesLoaded += value.length;
// Check if we've reached the maxBytes limit
if (job.maxBytes && job.bytesLoaded >= job.maxBytes) {
reachedLimit = true;
logger.info('[DownloadManager] Reached partial download limit', {
bytesLoaded: job.bytesLoaded,
maxBytes: job.maxBytes,
});
// Cancel the remaining download gracefully
try {
await reader.cancel();
} catch {
// Ignore cancel errors - stream may already be closed
}
break;
}
job.progress = job.progress =
job.totalBytes > 0 job.totalBytes > 0
? Math.round((job.bytesLoaded / job.totalBytes) * 100) ? Math.round((job.bytesLoaded / job.totalBytes) * 100)
@ -343,6 +283,30 @@ class DownloadManagerClass {
totalBytes: job.totalBytes, totalBytes: job.totalBytes,
}); });
// Check if streaming mode and minimum buffer reached
if (
job.streamingMode?.enabled &&
!job.streamingMode.didSignalReady &&
job.bytesLoaded >= job.streamingMode.minBufferBytes
) {
job.streamingMode.didSignalReady = true;
logger.info('[DownloadManager] Streaming ready', {
storageKey: job.storageKey.slice(-50),
bytesLoaded: job.bytesLoaded,
minBuffer: job.streamingMode.minBufferBytes,
});
// Emit streaming ready event
downloadEventBus.emitStreamingReady({
jobId: job.id,
storageKey: job.storageKey,
streamingUrl: job.streamingMode.streamingUrl,
bytesLoaded: job.bytesLoaded,
totalBytes: job.totalBytes,
});
}
// Only update queue progress if persisting // Only update queue progress if persisting
if (job.persist !== false) { if (job.persist !== false) {
await OfflineDbManager.updateQueueProgress( await OfflineDbManager.updateQueueProgress(
@ -357,11 +321,6 @@ class DownloadManagerClass {
type: type:
response.headers.get('content-type') || 'application/octet-stream', response.headers.get('content-type') || 'application/octet-stream',
}); });
// For partial downloads, mark as complete even if we didn't get everything
if (reachedLimit || isPartialResponse) {
job.progress = 100; // Consider partial download as "complete"
}
} else { } else {
// No streaming, get blob directly // No streaming, get blob directly
blob = await response.blob(); blob = await response.blob();
@ -371,34 +330,25 @@ class DownloadManagerClass {
} }
// Store the asset using canonical storage key // Store the asset using canonical storage key
// Partial downloads are now stored with isPartial: true for offline mode awareness
await StorageManager.storeAsset(job.storageKey, blob, { await StorageManager.storeAsset(job.storageKey, blob, {
id: job.assetId, id: job.assetId,
projectId: job.projectId, projectId: job.projectId,
filename: job.filename, filename: job.filename,
variantType: job.variantType, variantType: job.variantType,
assetType: job.assetType, assetType: job.assetType,
isPartial: job.isPartial || false, isPartial: false,
}); });
if (job.isPartial) { // Store ORIGINAL blob directly (bypass cache retrieval to avoid potential corruption)
// Mark partial download as ready in session cache this.readyBlobs.set(job.storageKey, blob);
this.partialDownloadsReady.add(job.storageKey); logger.info('[DownloadManager] Stored original blob', {
storageKey: job.storageKey.slice(-50),
blobSize: blob.size,
blobType: blob.type,
});
// Register with Service Worker for full-file caching during playback // Always create blob URL for reliable playback on all devices
// When the browser fetches the full media, SW will cache it using the storage key await this.createBlobUrlFromCache(job.storageKey);
this.registerUrlForCaching(job.url, job.storageKey);
logger.info('[DownloadManager] Partial download complete (stored)', {
storageKey: job.storageKey.slice(-50),
bytesLoaded: job.bytesLoaded,
});
} else {
// Create blob URL if requested (full downloads only)
if (job.createBlobUrl) {
await this.createBlobUrlFromCache(job.storageKey);
}
}
// Mark as completed // Mark as completed
job.status = 'completed'; job.status = 'completed';
@ -625,7 +575,6 @@ class DownloadManagerClass {
retryCount: item.retryCount, retryCount: item.retryCount,
addedAt: item.addedAt, addedAt: item.addedAt,
storageKey, storageKey,
createBlobUrl: true, // Create blob URL for resumed downloads
persist: true, persist: true,
}; };
@ -649,6 +598,16 @@ class DownloadManagerClass {
return this.readyBlobUrls.get(storageKey) || null; return this.readyBlobUrls.get(storageKey) || null;
} }
/**
* Get raw Blob for creating fresh blob URLs (O(1) lookup).
* Used by transitions to avoid decoder state issues with pre-created blob URLs.
* Returns null if blob not cached.
*/
getReadyBlob(url: string): Blob | null {
const storageKey = extractStoragePath(url);
return this.readyBlobs.get(storageKey) || null;
}
/** /**
* Cache an externally fetched blob and register blob URL for instant lookup. * Cache an externally fetched blob and register blob URL for instant lookup.
* Use this when fetching via XHR (e.g., transition playback) to enable caching. * Use this when fetching via XHR (e.g., transition playback) to enable caching.
@ -670,6 +629,9 @@ class DownloadManagerClass {
assetType: metadata.assetType, assetType: metadata.assetType,
}); });
// Store raw Blob for transitions (they need fresh blob URLs each playback)
this.readyBlobs.set(storageKey, blob);
// Create blob URL and register for instant O(1) lookup // Create blob URL and register for instant O(1) lookup
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
this.readyBlobUrls.set(storageKey, blobUrl); this.readyBlobUrls.set(storageKey, blobUrl);
@ -701,6 +663,13 @@ class DownloadManagerClass {
return; return;
} }
// Store raw Blob for transitions (they need fresh blob URLs each playback
// to avoid decoder state issues that cause video jumping)
// Only set if not already present (original download blob takes priority)
if (!this.readyBlobs.has(storageKey)) {
this.readyBlobs.set(storageKey, blob);
}
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
// Decode images to prevent white flash // Decode images to prevent white flash
@ -727,41 +696,13 @@ class DownloadManagerClass {
}); });
} }
} }
/** /**
* Register a presigned URL storage key mapping with the Service Worker. * Clear blob URLs cache (call on unmount to prevent memory leaks)
* This enables the SW to cache the full response when the browser fetches the media
* during playback, using the canonical storage key instead of the expiring presigned URL.
*/
private registerUrlForCaching(
presignedUrl: string,
storageKey: string,
): void {
if (navigator.serviceWorker?.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'REGISTER_CACHE_URL',
payload: { presignedUrl, storageKey },
});
logger.info('[DownloadManager] Registered URL for SW caching', {
storageKey: storageKey.slice(-40),
});
}
}
/**
* Clear blob URLs and partial downloads cache (call on unmount to prevent memory leaks)
*/ */
clearBlobUrls(): void { clearBlobUrls(): void {
this.readyBlobUrls.forEach((blobUrl) => URL.revokeObjectURL(blobUrl)); this.readyBlobUrls.forEach((blobUrl) => URL.revokeObjectURL(blobUrl));
this.readyBlobUrls.clear(); this.readyBlobUrls.clear();
this.partialDownloadsReady.clear(); this.readyBlobs.clear();
// Clear SW URL mappings (optional, SW has its own cleanup interval)
if (navigator.serviceWorker?.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'CLEAR_URL_MAPPINGS',
});
}
} }
/** /**
@ -774,7 +715,7 @@ class DownloadManagerClass {
URL.revokeObjectURL(blobUrl); URL.revokeObjectURL(blobUrl);
this.readyBlobUrls.delete(key); this.readyBlobUrls.delete(key);
} }
this.partialDownloadsReady.delete(key); this.readyBlobs.delete(key);
} }
} }
@ -813,6 +754,42 @@ class DownloadManagerClass {
} }
}); });
} }
/**
* Check if device is mobile (for streaming buffer size)
*/
private isMobile(): boolean {
if (typeof navigator === 'undefined') return false;
return /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
}
/**
* Get minimum buffer bytes based on device type
*/
private getMinBufferBytes(): number {
if (this.isMobile()) {
return PRELOAD_CONFIG.streaming.mobile?.minBufferBytes || 2 * 1024 * 1024;
}
return PRELOAD_CONFIG.streaming.minBufferBytes;
}
/**
* Check if asset is cached or get streaming URL for first playback.
* Returns cached blob URL for instant O(1) playback, or presigned URL for streaming.
*/
getStreamingUrlIfNeeded(
storageKey: string,
presignedUrl: string,
): { url: string; isFromCache: boolean } {
// Fully cached - use blob URL
const cachedUrl = this.readyBlobUrls.get(storageKey);
if (cachedUrl) {
return { url: cachedUrl, isFromCache: true };
}
// Not cached - use presigned URL for streaming
return { url: presignedUrl, isFromCache: false };
}
} }
// Singleton instance // Singleton instance

View File

@ -20,15 +20,30 @@ export const parseJsonObject = <T>(value?: unknown, fallback?: T): T => {
if (!value) return (fallback || ({} as T)) as T; if (!value) return (fallback || ({} as T)) as T;
try { try {
if (typeof value === 'string') { let result: unknown = value;
const parsed = JSON.parse(value);
return (parsed || fallback || {}) as T; // Handle string input - parse JSON
if (typeof result === 'string') {
result = JSON.parse(result);
// Handle double-encoded JSON (string that parses to another string)
// This can happen if JSON was stringified twice
while (typeof result === 'string') {
try {
result = JSON.parse(result);
} catch {
// Not valid JSON, use fallback
return (fallback || ({} as T)) as T;
}
}
} }
if (typeof value === 'object') { // Ensure we return an object, not a primitive
return value as T; if (result && typeof result === 'object' && !Array.isArray(result)) {
return result as T;
} }
// If it's an array or primitive, return fallback
return (fallback || ({} as T)) as T; return (fallback || ({} as T)) as T;
} catch { } catch {
return (fallback || ({} as T)) as T; return (fallback || ({} as T)) as T;

Some files were not shown because too many files have changed in this diff Show More