Compare commits
10 Commits
f5f9d3d6fc
...
027af5082b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
027af5082b | ||
|
|
06a29dbf6a | ||
|
|
ba813d2602 | ||
|
|
f06a2b2c97 | ||
|
|
4634ad9207 | ||
|
|
2073bee244 | ||
|
|
6581fd70c2 | ||
|
|
e4dd94f478 | ||
|
|
e855db03d1 | ||
|
|
5ef21543b3 |
155
backend/src/db/api/global_transition_defaults.js
Normal file
155
backend/src/db/api/global_transition_defaults.js
Normal 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;
|
||||
277
backend/src/db/api/project_transition_settings.js
Normal file
277
backend/src/db/api/project_transition_settings.js
Normal 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;
|
||||
@ -47,17 +47,20 @@ class ProjectsDBApi extends GenericDBApi {
|
||||
}
|
||||
|
||||
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 {
|
||||
id: data.id || undefined,
|
||||
name: data.name || null,
|
||||
slug: data.slug || null,
|
||||
description: data.description || null,
|
||||
logo_url: data.logo_url || null,
|
||||
favicon_url: data.favicon_url || null,
|
||||
og_image_url: data.og_image_url || null,
|
||||
design_width: data.design_width !== undefined ? data.design_width : null,
|
||||
design_height:
|
||||
data.design_height !== undefined ? data.design_height : null,
|
||||
name: 'name' in data ? data.name || null : undefined,
|
||||
slug: 'slug' in data ? data.slug || null : undefined,
|
||||
description: 'description' in data ? data.description || null : undefined,
|
||||
logo_url: 'logo_url' in data ? data.logo_url || null : undefined,
|
||||
favicon_url: 'favicon_url' in data ? data.favicon_url || null : undefined,
|
||||
og_image_url:
|
||||
'og_image_url' in data ? data.og_image_url || null : undefined,
|
||||
design_width: 'design_width' in data ? data.design_width : undefined,
|
||||
design_height: 'design_height' in data ? data.design_height : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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";',
|
||||
);
|
||||
},
|
||||
};
|
||||
73
backend/src/db/models/global_transition_defaults.js
Normal file
73
backend/src/db/models/global_transition_defaults.js
Normal 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;
|
||||
};
|
||||
103
backend/src/db/models/project_transition_settings.js
Normal file
103
backend/src/db/models/project_transition_settings.js
Normal 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;
|
||||
};
|
||||
@ -65,6 +65,9 @@ module.exports = function (sequelize, DataTypes) {
|
||||
defaultValue: 1080,
|
||||
},
|
||||
|
||||
// Note: transition_settings moved to project_transition_settings table
|
||||
// for environment-aware storage (dev, stage, production)
|
||||
|
||||
importHash: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
@ -172,6 +175,16 @@ module.exports = function (sequelize, DataTypes) {
|
||||
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
|
||||
|
||||
db.projects.belongsTo(db.users, {
|
||||
|
||||
@ -51,6 +51,8 @@ const pwa_cachesRoutes = require('./routes/pwa_caches');
|
||||
const access_logsRoutes = require('./routes/access_logs');
|
||||
const element_type_defaultsRoutes = require('./routes/element_type_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 runtimeContextRoutes = require('./routes/runtime-context');
|
||||
@ -141,8 +143,8 @@ app.use('/api/file/upload-sessions', uploadLimiter);
|
||||
app.use('/api/file', fileRoutes);
|
||||
|
||||
// Body parser for all other routes
|
||||
app.use(bodyParser.json({ limit: '1mb' }));
|
||||
app.use(bodyParser.urlencoded({ extended: true, limit: '1mb' }));
|
||||
app.use(bodyParser.json({ limit: '50mb' }));
|
||||
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
|
||||
app.use(runtimeContextMiddleware);
|
||||
|
||||
const requireRuntimeReadOrAuth = (req, res, next) => {
|
||||
@ -198,7 +200,7 @@ const mountRuntimeEntityRoute = (path, entityName, router) => {
|
||||
app.use(
|
||||
path,
|
||||
requireRuntimeReadOrAuth,
|
||||
blockNonPublicRuntimeListEndpoints,
|
||||
blockNonPublicRuntimeListEndpoints(entityName),
|
||||
sanitizePublicRuntimeListResponse(entityName),
|
||||
router,
|
||||
);
|
||||
@ -235,6 +237,11 @@ app.use(
|
||||
jwtAuth,
|
||||
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);
|
||||
|
||||
|
||||
@ -161,6 +161,8 @@ const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
|
||||
'PAGE_LINKS',
|
||||
'TRANSITIONS',
|
||||
'PROJECT_AUDIO_TRACKS',
|
||||
'GLOBAL_TRANSITION_DEFAULTS',
|
||||
'PROJECT_TRANSITION_SETTINGS',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
const PUBLIC_RUNTIME_ALLOWED_PATH = '/';
|
||||
|
||||
const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
||||
projects: [
|
||||
'id',
|
||||
@ -38,6 +36,31 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
||||
'sort_order',
|
||||
'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) => {
|
||||
@ -61,12 +84,17 @@ const isPublicRuntimeReadRequest = (req) => {
|
||||
return req.isRuntimePublicRequest === true && req.method === 'GET';
|
||||
};
|
||||
|
||||
const blockNonPublicRuntimeListEndpoints = (req, res, next) => {
|
||||
const blockNonPublicRuntimeListEndpoints = (entityName) => (req, res, next) => {
|
||||
if (!isPublicRuntimeReadRequest(req)) {
|
||||
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' });
|
||||
}
|
||||
|
||||
@ -79,11 +107,16 @@ const blockNonPublicRuntimeListEndpoints = (req, res, next) => {
|
||||
|
||||
const sanitizePublicRuntimeListResponse = (entityName) => {
|
||||
const fields = PUBLIC_RUNTIME_ENTITY_FIELDS[entityName] || [];
|
||||
const allowedPaths = PUBLIC_RUNTIME_ALLOWED_PATHS[entityName] || ['/'];
|
||||
|
||||
return (req, res, next) => {
|
||||
const pathMatches = allowedPaths.some((pattern) =>
|
||||
pattern instanceof RegExp ? pattern.test(req.path) : req.path === pattern,
|
||||
);
|
||||
|
||||
if (
|
||||
!isPublicRuntimeReadRequest(req) ||
|
||||
req.path !== PUBLIC_RUNTIME_ALLOWED_PATH ||
|
||||
!pathMatches ||
|
||||
fields.length === 0
|
||||
) {
|
||||
return next();
|
||||
@ -96,16 +129,21 @@ const sanitizePublicRuntimeListResponse = (entityName) => {
|
||||
return originalSend(body);
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.rows)) {
|
||||
return originalSend(body);
|
||||
// Handle list responses with rows array
|
||||
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({
|
||||
...body,
|
||||
rows: sanitizedRows,
|
||||
});
|
||||
return originalSend(body);
|
||||
};
|
||||
|
||||
return next();
|
||||
|
||||
131
backend/src/routes/global_transition_defaults.js
Normal file
131
backend/src/routes/global_transition_defaults.js
Normal 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;
|
||||
393
backend/src/routes/project_transition_settings.js
Normal file
393
backend/src/routes/project_transition_settings.js
Normal 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;
|
||||
@ -35,9 +35,20 @@ const getCachePath = (privateUrl) => {
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
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 age = (Date.now() - stats.mtimeMs) / 1000;
|
||||
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 provider = getFileStorageProvider();
|
||||
const privateUrl = req.query.privateUrl;
|
||||
@ -348,6 +391,7 @@ const downloadFile = async (req, res) => {
|
||||
}
|
||||
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
|
||||
// Create AbortController for request cancellation
|
||||
const abortController = new AbortController();
|
||||
@ -382,14 +426,6 @@ const downloadFile = async (req, res) => {
|
||||
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
|
||||
const ext = path.extname(privateUrl).toLowerCase();
|
||||
const mimeTypes = {
|
||||
@ -410,22 +446,84 @@ const downloadFile = async (req, res) => {
|
||||
res.setHeader('Content-Type', mimeTypes[ext]);
|
||||
}
|
||||
|
||||
log.debug(
|
||||
{
|
||||
provider,
|
||||
privateUrl,
|
||||
duration: Date.now() - startTime,
|
||||
cached: true,
|
||||
},
|
||||
'File served from cache',
|
||||
// Handle Range requests for cached files
|
||||
const rangeHeader = req.headers.range;
|
||||
if (rangeHeader) {
|
||||
const range = parseRangeHeader(rangeHeader, stats.size);
|
||||
if (range) {
|
||||
const { start, end } = range;
|
||||
const chunkSize = end - start + 1;
|
||||
|
||||
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
|
||||
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 });
|
||||
|
||||
if (result.contentType) res.setHeader('Content-Type', result.contentType);
|
||||
@ -433,15 +531,17 @@ const downloadFile = async (req, res) => {
|
||||
res.setHeader('Content-Length', result.contentLength);
|
||||
|
||||
// Add caching headers for browser
|
||||
res.setHeader(
|
||||
'Cache-Control',
|
||||
`public, max-age=${config.s3CacheMaxAge}`,
|
||||
);
|
||||
res.setHeader('Cache-Control', `public, max-age=${config.s3CacheMaxAge}`);
|
||||
|
||||
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();
|
||||
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
|
||||
const { PassThrough } = require('stream');
|
||||
@ -451,8 +551,43 @@ const downloadFile = async (req, res) => {
|
||||
passThrough.pipe(res);
|
||||
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');
|
||||
// Cleanup temp and marker files
|
||||
await fs.promises.unlink(tempPath).catch(() => {});
|
||||
await fs.promises.unlink(downloadingPath).catch(() => {});
|
||||
});
|
||||
} else if (typeof result.body.pipe === 'function') {
|
||||
result.body.pipe(res);
|
||||
@ -460,12 +595,17 @@ const downloadFile = async (req, res) => {
|
||||
const bytes = await result.body.transformToByteArray();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
// Cache the buffer
|
||||
// Cache the buffer atomically (write to temp, then rename)
|
||||
if (useCache) {
|
||||
await ensureCacheDir();
|
||||
fs.promises.writeFile(cachePath, buffer).catch((err) => {
|
||||
log.warn({ err, cachePath }, 'Failed to write to cache');
|
||||
});
|
||||
const tempPath = cachePath + '.tmp';
|
||||
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);
|
||||
@ -494,7 +634,58 @@ const downloadFile = async (req, res) => {
|
||||
.send(createErrorResponse('File not found', 'NOT_FOUND'));
|
||||
}
|
||||
} 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) {
|
||||
// Don't log abort errors as they're expected when client disconnects
|
||||
@ -703,11 +894,6 @@ const initUploadSession = async (req, res) => {
|
||||
contentType,
|
||||
});
|
||||
|
||||
log.info(
|
||||
{ sessionId, folder, filename, totalChunks, size },
|
||||
'Upload session initialized',
|
||||
);
|
||||
|
||||
return res.status(200).send({
|
||||
sessionId,
|
||||
uploadedChunks: [],
|
||||
@ -839,13 +1025,11 @@ const finalizeUploadSession = async (req, res) => {
|
||||
// Verify all chunks exist
|
||||
for (let i = 0; i < session.totalChunks; i++) {
|
||||
if (!sessionManager.chunkExists(sessionId, i)) {
|
||||
return res
|
||||
.status(400)
|
||||
.send(
|
||||
createErrorResponse(`Missing chunk ${i}`, 'MISSING_CHUNK', {
|
||||
missingChunk: i,
|
||||
}),
|
||||
);
|
||||
return res.status(400).send(
|
||||
createErrorResponse(`Missing chunk ${i}`, 'MISSING_CHUNK', {
|
||||
missingChunk: i,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -864,6 +1048,7 @@ const finalizeUploadSession = async (req, res) => {
|
||||
if (provider === 's3') {
|
||||
const s3 = getS3Provider();
|
||||
const data = fs.readFileSync(assembledPath);
|
||||
|
||||
const result = await s3.upload(privateUrl, data, {
|
||||
contentType: session.contentType,
|
||||
});
|
||||
@ -956,12 +1141,16 @@ const getMimeTypeFromExtension = (filepath) => {
|
||||
*/
|
||||
const copyFile = async (sourceKey, destKey, options = {}) => {
|
||||
const provider = getFileStorageProvider();
|
||||
const contentType = options.contentType || getMimeTypeFromExtension(sourceKey);
|
||||
const contentType =
|
||||
options.contentType || getMimeTypeFromExtension(sourceKey);
|
||||
|
||||
if (provider === 's3') {
|
||||
const s3 = getS3Provider();
|
||||
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 };
|
||||
}
|
||||
|
||||
@ -969,7 +1158,9 @@ const copyFile = async (sourceKey, destKey, options = {}) => {
|
||||
const local = getLocalProvider();
|
||||
await local.copy(sourceKey, destKey);
|
||||
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)
|
||||
@ -1029,13 +1220,20 @@ const copyFilesParallel = async (copies, options = {}) => {
|
||||
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(
|
||||
{ succeeded: succeeded.length, failed: failed.length, total: copies.length },
|
||||
{
|
||||
succeeded: succeeded.length,
|
||||
failed: failed.length,
|
||||
total: copies.length,
|
||||
},
|
||||
'Batch file copy completed',
|
||||
);
|
||||
|
||||
|
||||
@ -227,18 +227,44 @@ class S3StorageProvider extends BaseStorageProvider {
|
||||
* @param {string} key - Storage key/path
|
||||
* @param {Object} [options] - Download options
|
||||
* @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 }>}
|
||||
*/
|
||||
async download(key, options = {}) {
|
||||
const fullKey = this.buildKey(key);
|
||||
const { signal } = options;
|
||||
const { signal, headOnly, range } = options;
|
||||
|
||||
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(
|
||||
new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: fullKey,
|
||||
}),
|
||||
new GetObjectCommand(commandParams),
|
||||
sendOptions,
|
||||
);
|
||||
|
||||
|
||||
6
backend/src/services/global_transition_defaults.js
Normal file
6
backend/src/services/global_transition_defaults.js
Normal 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',
|
||||
});
|
||||
162
backend/src/services/project_transition_settings.js
Normal file
162
backend/src/services/project_transition_settings.js
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -293,10 +293,13 @@ class ProjectsService extends BaseProjectsService {
|
||||
'Starting parallel file copy for project clone',
|
||||
);
|
||||
|
||||
const { succeeded, failed } = await FileService.copyFilesParallel(copyOperations, {
|
||||
concurrency: 10,
|
||||
continueOnError: true,
|
||||
});
|
||||
const { succeeded, failed } = await FileService.copyFilesParallel(
|
||||
copyOperations,
|
||||
{
|
||||
concurrency: 10,
|
||||
continueOnError: true,
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Phase D: Build assetPathMap from results
|
||||
@ -322,7 +325,9 @@ class ProjectsService extends BaseProjectsService {
|
||||
asset_type: sourceAsset.asset_type,
|
||||
type: sourceAsset.type || 'general',
|
||||
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,
|
||||
size_mb: sourceAsset.size_mb,
|
||||
width_px: sourceAsset.width_px,
|
||||
@ -345,7 +350,8 @@ class ProjectsService extends BaseProjectsService {
|
||||
if (sourceVariant.variant_type === 'reversed') continue; // Handled in Phase F
|
||||
|
||||
const variantStorageKey =
|
||||
assetPathMap.get(sourceVariant.storage_key) || sourceVariant.storage_key;
|
||||
assetPathMap.get(sourceVariant.storage_key) ||
|
||||
sourceVariant.storage_key;
|
||||
|
||||
await db.asset_variants.create(
|
||||
{
|
||||
@ -385,10 +391,13 @@ class ProjectsService extends BaseProjectsService {
|
||||
'Copying reversed videos for cloned assets',
|
||||
);
|
||||
|
||||
const reversedResults = await FileService.copyFilesParallel(reversedCopyOps, {
|
||||
concurrency: 10,
|
||||
continueOnError: true, // Many assets won't have reversed videos - that's OK
|
||||
});
|
||||
const reversedResults = await FileService.copyFilesParallel(
|
||||
reversedCopyOps,
|
||||
{
|
||||
concurrency: 10,
|
||||
continueOnError: true, // Many assets won't have reversed videos - that's OK
|
||||
},
|
||||
);
|
||||
|
||||
// Add successful reversed video copies to assetPathMap
|
||||
for (const { sourceKey, destKey } of reversedResults.succeeded) {
|
||||
@ -431,15 +440,18 @@ class ProjectsService extends BaseProjectsService {
|
||||
// Transform background URLs to new storage keys
|
||||
if (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) {
|
||||
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) {
|
||||
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(
|
||||
|
||||
@ -257,16 +257,21 @@ module.exports = class PublishService {
|
||||
transaction,
|
||||
) {
|
||||
// Get source content
|
||||
const [sourcePages, sourceAudioTracks] = await Promise.all([
|
||||
db.tour_pages.findAll({
|
||||
where: { projectId, environment: fromEnv },
|
||||
transaction,
|
||||
}),
|
||||
db.project_audio_tracks.findAll({
|
||||
where: { projectId, environment: fromEnv },
|
||||
transaction,
|
||||
}),
|
||||
]);
|
||||
const [sourcePages, sourceAudioTracks, sourceTransitionSettings] =
|
||||
await Promise.all([
|
||||
db.tour_pages.findAll({
|
||||
where: { projectId, environment: fromEnv },
|
||||
transaction,
|
||||
}),
|
||||
db.project_audio_tracks.findAll({
|
||||
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)
|
||||
await Promise.all([
|
||||
@ -280,6 +285,11 @@ module.exports = class PublishService {
|
||||
transaction,
|
||||
force: true,
|
||||
}),
|
||||
db.project_transition_settings.destroy({
|
||||
where: { projectId, environment: toEnv },
|
||||
transaction,
|
||||
force: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
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 {
|
||||
pages_copied: sourcePages.length,
|
||||
audios_copied: sourceAudioTracks.length,
|
||||
transition_settings_copied: sourceTransitionSettings ? 1 : 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
BIN
frontend/public/fonts/MapleMedium.otf
Normal file
BIN
frontend/public/fonts/MapleMedium.otf
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
90
frontend/src/components/CanvasLoadingSpinner.tsx
Normal file
90
frontend/src/components/CanvasLoadingSpinner.tsx
Normal 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;
|
||||
@ -58,7 +58,7 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
|
||||
|
||||
return (
|
||||
<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>
|
||||
<select
|
||||
@ -81,17 +81,17 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
|
||||
))}
|
||||
</select>
|
||||
{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 */}
|
||||
{showVideoSettings && (
|
||||
<div className='mt-3 space-y-2 border-t border-gray-200 pt-3'>
|
||||
<p className='text-[10px] font-semibold uppercase text-gray-500'>
|
||||
<div className='mt-3 space-y-2 border-t border-white/20 pt-3'>
|
||||
<p className='text-[10px] font-semibold uppercase text-white/70'>
|
||||
Playback Settings
|
||||
</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
|
||||
type='checkbox'
|
||||
className='h-3 w-3 rounded border-gray-300'
|
||||
@ -103,7 +103,7 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
|
||||
Autoplay
|
||||
</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
|
||||
type='checkbox'
|
||||
className='h-3 w-3 rounded border-gray-300'
|
||||
@ -115,7 +115,7 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
|
||||
Loop
|
||||
</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
|
||||
type='checkbox'
|
||||
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-1'>
|
||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||
<label className='mb-1 block text-[10px] text-white/60'>
|
||||
Start (sec)
|
||||
</label>
|
||||
<input
|
||||
@ -149,7 +149,7 @@ const BackgroundSettingsEditor: React.FC<BackgroundSettingsEditorProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<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)
|
||||
</label>
|
||||
<input
|
||||
|
||||
@ -6,11 +6,28 @@
|
||||
* 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 { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback';
|
||||
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+)
|
||||
// The callback receives (now: DOMHighResTimeStamp, metadata: VideoFrameCallbackMetadata)
|
||||
@ -32,8 +49,9 @@ interface CanvasBackgroundProps {
|
||||
previousBgVideoUrl?: string;
|
||||
isSwitching?: boolean;
|
||||
isNewBgReady?: boolean;
|
||||
isFadingIn?: boolean;
|
||||
onBackgroundReady?: () => void;
|
||||
/** Callback when video buffer state changes (true = buffering, false = ready) */
|
||||
onVideoBufferStateChange?: (isBuffering: boolean) => void;
|
||||
// Video playback settings
|
||||
videoAutoplay?: boolean;
|
||||
videoLoop?: boolean;
|
||||
@ -42,6 +60,8 @@ interface CanvasBackgroundProps {
|
||||
videoEndTime?: number | null;
|
||||
/** Original storage path for video - used for play-once tracking (not the resolved blob URL) */
|
||||
videoStoragePath?: string;
|
||||
/** Pause video playback (e.g., during navigation to show frozen frame) */
|
||||
pauseVideo?: boolean;
|
||||
}
|
||||
|
||||
const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
@ -52,49 +72,258 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
previousBgVideoUrl,
|
||||
isSwitching = false,
|
||||
isNewBgReady = false,
|
||||
isFadingIn = false,
|
||||
onBackgroundReady,
|
||||
onVideoBufferStateChange,
|
||||
videoAutoplay = true,
|
||||
videoLoop = true,
|
||||
videoMuted = true,
|
||||
videoStartTime = null,
|
||||
videoEndTime = null,
|
||||
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 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({
|
||||
videoUrl: backgroundVideoUrl,
|
||||
videoUrl: activeVideoUrl,
|
||||
videoStoragePath: videoStoragePath || backgroundVideoUrl,
|
||||
autoplay: videoAutoplay,
|
||||
loop: videoLoop,
|
||||
muted: videoMuted,
|
||||
startTime: videoStartTime,
|
||||
endTime: videoEndTime,
|
||||
paused: pauseVideo,
|
||||
});
|
||||
|
||||
// Block autoplay if video already played this session (when loop=false)
|
||||
const effectiveAutoplay = videoAutoplay && !shouldBlockAutoplay;
|
||||
// Block autoplay if: video already played this session OR externally paused
|
||||
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.
|
||||
// This prevents the transition overlay from being removed before the background is visible.
|
||||
scheduleAfterPaint(() => {
|
||||
onBackgroundReady?.();
|
||||
});
|
||||
};
|
||||
}, [onBackgroundReady, backgroundImageUrl]);
|
||||
|
||||
const handleError = () => {
|
||||
const handleError = useCallback(() => {
|
||||
if (didReportImageReadyRef.current) return;
|
||||
didReportImageReadyRef.current = true;
|
||||
onBackgroundReady?.();
|
||||
};
|
||||
}, [onBackgroundReady]);
|
||||
|
||||
// Track if we've already called onBackgroundReady to avoid double calls
|
||||
const didReportReadyRef = useRef(false);
|
||||
|
||||
// Reset ready flag when video URL changes
|
||||
// Handle already-loaded images (blob URLs from preload cache)
|
||||
// The onLoad event may not fire for images that are already in memory
|
||||
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;
|
||||
}, [backgroundVideoUrl]);
|
||||
prevVideoUrlRef.current = backgroundVideoUrl;
|
||||
}
|
||||
|
||||
// Handle video first frame ready using requestVideoFrameCallback
|
||||
// This ensures the video's first frame is actually painted before we report ready
|
||||
@ -108,15 +337,31 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
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+)
|
||||
// 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;
|
||||
if (typeof videoWithRVFC.requestVideoFrameCallback === 'function') {
|
||||
videoWithRVFC.requestVideoFrameCallback(() => {
|
||||
reportVideoReady();
|
||||
clearTimeout(timeout);
|
||||
scheduleAfterPaint(() => {
|
||||
reportVideoReady();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Fallback: use playing event + scheduleAfterPaint
|
||||
const onPlaying = () => {
|
||||
clearTimeout(timeout);
|
||||
scheduleAfterPaint(() => {
|
||||
reportVideoReady();
|
||||
});
|
||||
@ -124,22 +369,39 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
|
||||
video.addEventListener('playing', onPlaying, { once: true });
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
video.removeEventListener('playing', onPlaying);
|
||||
};
|
||||
}
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [backgroundVideoUrl, onBackgroundReady]);
|
||||
|
||||
// When endTime is set, we disable native loop and handle it via the hook
|
||||
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 (
|
||||
<>
|
||||
{/* 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 && (
|
||||
<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:') ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
ref={imageRef}
|
||||
key={`bg_image_${backgroundImageUrl}`}
|
||||
src={backgroundImageUrl}
|
||||
alt='Background'
|
||||
@ -149,49 +411,57 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
onError={handleError}
|
||||
/>
|
||||
) : (
|
||||
<NextImage
|
||||
key={`bg_image_${backgroundImageUrl}`}
|
||||
src={backgroundImageUrl}
|
||||
alt='Background'
|
||||
fill
|
||||
sizes='100vw'
|
||||
className='object-contain'
|
||||
draggable={false}
|
||||
unoptimized
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
<div
|
||||
ref={nextImageWrapperRef}
|
||||
className='absolute inset-0 h-full w-full'
|
||||
>
|
||||
<NextImage
|
||||
key={`bg_image_${backgroundImageUrl}`}
|
||||
src={backgroundImageUrl}
|
||||
alt='Background'
|
||||
fill
|
||||
sizes='100vw'
|
||||
className='object-contain'
|
||||
draggable={false}
|
||||
unoptimized
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Previous background overlays - show during loading AND crossfade.
|
||||
Uses CSS animation for fade-out effect during crossfade.
|
||||
z-0 keeps them BELOW new backgrounds (z-1). */}
|
||||
{/* Previous background overlay - shows during loading (z-2) above new background (z-1).
|
||||
Black overlay for fade effect is rendered separately at higher z-index (TransitionBlackOverlay). */}
|
||||
<PreviousBackgroundOverlay
|
||||
imageUrl={previousBgImageUrl}
|
||||
videoUrl={previousBgVideoUrl}
|
||||
isSwitching={isSwitching}
|
||||
isNewBgReady={isNewBgReady}
|
||||
isFadingIn={isFadingIn}
|
||||
paused={pauseVideo}
|
||||
/>
|
||||
|
||||
{/* Background video - z-1 keeps it below backdrop blur layer (z-5)
|
||||
Note: muted attribute is always true for iOS autoplay compatibility.
|
||||
Actual muted state is controlled via useBackgroundVideoPlayback hook
|
||||
which sets video.muted property via JavaScript (useEffect).
|
||||
webkit-playsinline is legacy attribute for older iOS versions. */}
|
||||
{backgroundVideoUrl && (
|
||||
webkit-playsinline is legacy attribute for older iOS versions.
|
||||
preload="metadata" is required for iOS Safari video initialization.
|
||||
Video fades in when ready (opacity transition from 0 to 1). */}
|
||||
{activeVideoUrl && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
key={`bg_video_${backgroundVideoUrl}`}
|
||||
key={`bg_video_${activeVideoUrl}`}
|
||||
className='absolute inset-0 z-1 h-full w-full object-contain'
|
||||
src={backgroundVideoUrl}
|
||||
src={videoSrc}
|
||||
preload='auto'
|
||||
autoPlay={effectiveAutoplay}
|
||||
loop={useNativeLoop}
|
||||
muted={videoMuted}
|
||||
playsInline
|
||||
webkit-playsinline=''
|
||||
onError={handleVideoError}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -17,12 +17,13 @@ import {
|
||||
type ElementEffectProperties,
|
||||
} from '../../lib/elementEffects';
|
||||
import type { CanvasElement as CanvasElementType } from '../../types/constructor';
|
||||
import type { ResolvedTransitionSettings } from '../../types/transition';
|
||||
import type { PreloadCacheProvider } from '../../hooks/video';
|
||||
|
||||
interface CanvasElementProps {
|
||||
element: CanvasElementType;
|
||||
isSelected: boolean;
|
||||
isEditMode: boolean;
|
||||
isDisabled?: boolean;
|
||||
onClick: () => void;
|
||||
onMouseDown?: (event: React.MouseEvent) => void;
|
||||
/** Optional URL resolver for preloaded blob URLs */
|
||||
@ -37,19 +38,24 @@ interface CanvasElementProps {
|
||||
) => void;
|
||||
/** Letterbox styles for constraining fullscreen elements to canvas bounds */
|
||||
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> = ({
|
||||
element,
|
||||
isSelected,
|
||||
isEditMode,
|
||||
isDisabled = false,
|
||||
onClick,
|
||||
onMouseDown,
|
||||
resolveUrl,
|
||||
onGalleryCardClick,
|
||||
onCarouselButtonPositionChange,
|
||||
letterboxStyles,
|
||||
pageTransitionSettings,
|
||||
preloadCache,
|
||||
}) => {
|
||||
// Extract effect properties from element
|
||||
const effectProperties: Partial<ElementEffectProperties> = {
|
||||
@ -87,11 +93,6 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
||||
left: `${xClamped}%`,
|
||||
top: `${yClamped}%`,
|
||||
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)
|
||||
@ -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 (
|
||||
<button
|
||||
type='button'
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
data-constructor-element-id={element.id}
|
||||
className='absolute'
|
||||
className='absolute cursor-pointer'
|
||||
style={positionStyle}
|
||||
onMouseDown={isEditMode ? onMouseDown : undefined}
|
||||
onClick={onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...(!isEditMode ? eventHandlers : {})}
|
||||
>
|
||||
<UiElementRenderer
|
||||
@ -136,12 +147,13 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
||||
resolveUrl={resolveUrl}
|
||||
isSelected={isSelected}
|
||||
isEditMode={isEditMode}
|
||||
isDisabled={isDisabled}
|
||||
onGalleryCardClick={onGalleryCardClick}
|
||||
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
|
||||
letterboxStyles={letterboxStyles}
|
||||
pageTransitionSettings={pageTransitionSettings}
|
||||
preloadCache={preloadCache}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
352
frontend/src/components/Constructor/ConstructorToolbar.tsx
Normal file
352
frontend/src/components/Constructor/ConstructorToolbar.tsx
Normal 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;
|
||||
123
frontend/src/components/Constructor/CreatePageModal.tsx
Normal file
123
frontend/src/components/Constructor/CreatePageModal.tsx
Normal 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;
|
||||
@ -36,8 +36,8 @@ const CreateTransitionForm: React.FC<CreateTransitionFormProps> = ({
|
||||
onSubmit,
|
||||
}) => {
|
||||
return (
|
||||
<div className='rounded border border-gray-200 p-2 space-y-2'>
|
||||
<p className='text-[11px] font-semibold text-gray-600'>
|
||||
<div className='rounded border border-white/20 p-2 space-y-2'>
|
||||
<p className='text-[11px] font-semibold text-white/80'>
|
||||
Create next page transition
|
||||
</p>
|
||||
|
||||
@ -61,11 +61,11 @@ const CreateTransitionForm: React.FC<CreateTransitionFormProps> = ({
|
||||
))}
|
||||
</select>
|
||||
|
||||
<p className='text-[11px] text-gray-500'>
|
||||
<p className='text-[11px] text-white/60'>
|
||||
Transition duration is automatic from video metadata. {durationNote}
|
||||
</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
|
||||
type='checkbox'
|
||||
checked={supportsReverse}
|
||||
|
||||
@ -26,16 +26,16 @@ const ElementEditorHeader: React.FC<ElementEditorHeaderProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<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}
|
||||
</p>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='flex items-center gap-1.5 shrink-0'>
|
||||
<button
|
||||
type='button'
|
||||
className='text-xs text-gray-700 hover:underline'
|
||||
className='text-[10px] text-white/70 hover:text-white hover:underline'
|
||||
onClick={onToggleCollapse}
|
||||
>
|
||||
{isCollapsed ? 'Expand' : 'Collapse'}
|
||||
@ -43,10 +43,10 @@ const ElementEditorHeader: React.FC<ElementEditorHeaderProps> = ({
|
||||
{showRemoveButton && (
|
||||
<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}
|
||||
>
|
||||
Remove element
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -183,7 +183,7 @@ export function ElementEditorPanel({
|
||||
return (
|
||||
<div
|
||||
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 }}
|
||||
>
|
||||
<ElementEditorHeader
|
||||
@ -324,6 +324,14 @@ export function ElementEditorPanel({
|
||||
selectedElement.transitionReverseMode || 'auto_reverse'
|
||||
}
|
||||
reverseVideoUrl={selectedElement.reverseVideoUrl || ''}
|
||||
transitionType={selectedElement.transitionType || ''}
|
||||
transitionDurationMs={
|
||||
selectedElement.transitionDurationMs ?? ''
|
||||
}
|
||||
transitionEasing={selectedElement.transitionEasing || ''}
|
||||
transitionOverlayColor={
|
||||
selectedElement.transitionOverlayColor || ''
|
||||
}
|
||||
allowedNavigationTypes={allowedNavigationTypes}
|
||||
iconAssetOptions={assetOptions.icon}
|
||||
transitionVideoOptions={assetOptions.transitionVideo}
|
||||
@ -539,7 +547,7 @@ export function ElementEditorPanel({
|
||||
{/* Gallery Section Styles (shown first for gallery elements) */}
|
||||
{isGalleryElementType(selectedElement.type) && (
|
||||
<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
|
||||
</p>
|
||||
<GallerySectionStyleInputs
|
||||
@ -684,7 +692,7 @@ export function ElementEditorPanel({
|
||||
showTitleStyles
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
@ -733,6 +741,7 @@ export function ElementEditorPanel({
|
||||
{/* Effects Tab */}
|
||||
{activeTab === 'effects' && (
|
||||
<EffectsSettingsSectionCompact
|
||||
elementType={selectedElement.type}
|
||||
values={{
|
||||
appearAnimation: selectedElement.appearAnimation || '',
|
||||
appearAnimationDuration:
|
||||
@ -755,11 +764,106 @@ export function ElementEditorPanel({
|
||||
activeOpacity: selectedElement.activeOpacity || '',
|
||||
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) => {
|
||||
updateSelectedElement({
|
||||
[prop]: value || undefined,
|
||||
});
|
||||
// Handle slide transition properties with proper prefixes
|
||||
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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -10,23 +10,25 @@ import type { ConstructorInteractionMode } from './types';
|
||||
interface InteractionModeToggleProps {
|
||||
mode: ConstructorInteractionMode;
|
||||
onModeChange: (mode: ConstructorInteractionMode) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const InteractionModeToggle: React.FC<InteractionModeToggleProps> = ({
|
||||
mode,
|
||||
onModeChange,
|
||||
compact = false,
|
||||
}) => {
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
return (
|
||||
<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
|
||||
type='button'
|
||||
className={`px-3 py-1.5 ${
|
||||
className={`px-3 py-1.5 transition-colors ${
|
||||
isEditMode
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-50'
|
||||
? 'bg-blue-500/80 text-white'
|
||||
: 'text-white/70 hover:bg-white/10'
|
||||
}`}
|
||||
onClick={() => onModeChange('edit')}
|
||||
>
|
||||
@ -34,21 +36,23 @@ const InteractionModeToggle: React.FC<InteractionModeToggleProps> = ({
|
||||
</button>
|
||||
<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
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-50'
|
||||
? 'bg-blue-500/80 text-white'
|
||||
: 'text-white/70 hover:bg-white/10'
|
||||
}`}
|
||||
onClick={() => onModeChange('interact')}
|
||||
>
|
||||
Interact mode
|
||||
</button>
|
||||
</div>
|
||||
<span className='text-[11px] text-gray-600'>
|
||||
{isEditMode
|
||||
? 'Drag & configure elements.'
|
||||
: 'Click and interact with rendered elements.'}
|
||||
</span>
|
||||
{!compact && (
|
||||
<span className='text-[11px] text-white/60'>
|
||||
{isEditMode
|
||||
? 'Drag & configure elements.'
|
||||
: 'Click and interact with rendered elements.'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* MenuActionButton Component
|
||||
*
|
||||
* 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';
|
||||
|
||||
@ -45,7 +45,10 @@ const PageSelector: React.FC<PageSelectorProps> = ({
|
||||
|
||||
return (
|
||||
<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 ?? ''}
|
||||
onChange={(event) => onPageChange(event.target.value)}
|
||||
disabled={disabled}
|
||||
|
||||
@ -7,63 +7,122 @@
|
||||
*
|
||||
* Supports letterbox mode to constrain transitions within canvas bounds,
|
||||
* 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 {
|
||||
/** Reference to the video element - useTransitionPlayback manages src and playback */
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
/** Whether the overlay is visible */
|
||||
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;
|
||||
/** 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 */
|
||||
letterboxStyles?: React.CSSProperties;
|
||||
/** Video object-fit mode (default: 'contain' to match backgrounds) */
|
||||
videoFit?: 'contain' | 'cover';
|
||||
/** Additional opacity value for fade-out effects (0-1) */
|
||||
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> = ({
|
||||
videoRef,
|
||||
isActive,
|
||||
isBuffering = false,
|
||||
isVideoReady = false,
|
||||
showSpinner = false,
|
||||
letterboxStyles,
|
||||
videoFit = 'contain',
|
||||
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;
|
||||
|
||||
// Container opacity: 0 while buffering to prevent black flash
|
||||
// Video first frame = old page background, so we hide everything until ready
|
||||
const containerOpacity = isBuffering ? 0 : (opacity ?? 1);
|
||||
// Container opacity:
|
||||
// - 0 during initial buffering (before first frame displayed)
|
||||
// - 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 (
|
||||
// Outer: full viewport, transparent background
|
||||
// Transparent ensures if Safari clears video frame when paused,
|
||||
// the new page background shows through instead of black flash
|
||||
<div
|
||||
className='fixed inset-0 z-50 overflow-hidden pointer-events-none'
|
||||
style={{ opacity: containerOpacity }}
|
||||
>
|
||||
{/* Inner: respects letterbox dimensions when provided */}
|
||||
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
|
||||
{/* Loading spinner during buffering */}
|
||||
{isBuffering && showSpinner && (
|
||||
<CanvasLoadingSpinner isVisible={true} size='lg' zIndex={60} />
|
||||
)}
|
||||
|
||||
{/* Video container - hidden while buffering or fading out */}
|
||||
<div
|
||||
className='overflow-hidden'
|
||||
style={letterboxStyles || { position: 'absolute', inset: 0 }}
|
||||
style={{
|
||||
opacity: containerOpacity,
|
||||
transition: useTransition ? 'opacity 150ms ease-out' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Video element - container handles visibility, video is always opaque */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={`absolute inset-0 h-full w-full ${
|
||||
videoFit === 'cover' ? 'object-cover' : 'object-contain'
|
||||
}`}
|
||||
muted
|
||||
playsInline
|
||||
preload='auto'
|
||||
disablePictureInPicture
|
||||
/>
|
||||
{/* Inner: respects letterbox dimensions when provided */}
|
||||
<div
|
||||
className='overflow-hidden'
|
||||
style={letterboxStyles || { position: 'absolute', inset: 0 }}
|
||||
>
|
||||
{/* Video element - container handles visibility, video is always opaque */}
|
||||
{/* key forces React to remount the video element when URL changes, clearing decoder state */}
|
||||
<video
|
||||
key={videoKey}
|
||||
ref={videoRef}
|
||||
className={`absolute inset-0 h-full w-full ${
|
||||
videoFit === 'cover' ? 'object-cover' : 'object-contain'
|
||||
}`}
|
||||
muted
|
||||
playsInline
|
||||
preload='auto'
|
||||
disablePictureInPicture
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*
|
||||
* Types only - import components directly from their files:
|
||||
* import CanvasElement from './Constructor/CanvasElement';
|
||||
* import ConstructorMenu from './Constructor/ConstructorMenu';
|
||||
* import ConstructorToolbar from './Constructor/ConstructorToolbar';
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
|
||||
@ -83,6 +83,7 @@ export interface PageSelectorProps {
|
||||
export interface InteractionModeToggleProps {
|
||||
mode: ConstructorInteractionMode;
|
||||
onModeChange: (mode: ConstructorInteractionMode) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -108,7 +109,6 @@ export interface CanvasElementProps {
|
||||
element: CanvasElement;
|
||||
isSelected: boolean;
|
||||
isEditMode: boolean;
|
||||
isDisabled?: boolean;
|
||||
canvasElapsedSec: number;
|
||||
preloadedIconUrl: boolean;
|
||||
onClick: (element: CanvasElement) => void;
|
||||
@ -301,6 +301,48 @@ export interface ConstructorMenuProps {
|
||||
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
|
||||
*/
|
||||
|
||||
@ -71,14 +71,14 @@ const CarouselSettingsSectionCompact: React.FC<
|
||||
/>
|
||||
<label
|
||||
htmlFor='carouselFullWidth'
|
||||
className='text-[11px] text-gray-700'
|
||||
className='text-[11px] text-white/80'
|
||||
>
|
||||
Full-width mode (background layer)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className='rounded border border-gray-200 p-2 space-y-2'>
|
||||
<p className='text-[11px] font-semibold text-gray-700'>
|
||||
<div className='rounded border border-white/20 p-2 space-y-2'>
|
||||
<p className='text-[11px] font-semibold text-white/90'>
|
||||
Navigation icons
|
||||
</p>
|
||||
|
||||
@ -177,7 +177,7 @@ const CarouselSettingsSectionCompact: React.FC<
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className='text-[10px] text-gray-600'>Caption font:</label>
|
||||
<label className='text-[10px] text-white/70'>Caption font:</label>
|
||||
<select
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={carouselCaptionFontFamily}
|
||||
@ -195,7 +195,7 @@ const CarouselSettingsSectionCompact: React.FC<
|
||||
</div>
|
||||
|
||||
{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
|
||||
buttons. Drag to reposition in editor.
|
||||
</p>
|
||||
@ -203,7 +203,7 @@ const CarouselSettingsSectionCompact: React.FC<
|
||||
</div>
|
||||
|
||||
<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
|
||||
</p>
|
||||
<button
|
||||
@ -218,10 +218,10 @@ const CarouselSettingsSectionCompact: React.FC<
|
||||
{carouselSlides.map((slide, index) => (
|
||||
<div
|
||||
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'>
|
||||
<p className='text-[11px] font-semibold text-gray-700'>
|
||||
<p className='text-[11px] font-semibold text-white/90'>
|
||||
Slide {index + 1}
|
||||
</p>
|
||||
<button
|
||||
@ -264,7 +264,7 @@ const CarouselSettingsSectionCompact: React.FC<
|
||||
))}
|
||||
|
||||
{carouselSlides.length === 0 && (
|
||||
<p className='text-[11px] text-gray-500'>
|
||||
<p className='text-[11px] text-white/60'>
|
||||
No slides yet. Click "+ Add slide" to create one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -16,25 +16,25 @@ const CommonSettingsSectionCompact: React.FC<CommonSettingsSectionProps> = ({
|
||||
showLabel = true,
|
||||
}) => {
|
||||
return (
|
||||
<div className='mb-2 space-y-2'>
|
||||
<div className='mb-1.5 space-y-1.5'>
|
||||
{showLabel && (
|
||||
<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>
|
||||
<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}
|
||||
onChange={(event) => onChange('label', event.target.value)}
|
||||
/>
|
||||
</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)
|
||||
</label>
|
||||
<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'
|
||||
min='0'
|
||||
step='0.1'
|
||||
@ -43,11 +43,11 @@ const CommonSettingsSectionCompact: React.FC<CommonSettingsSectionProps> = ({
|
||||
/>
|
||||
</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)
|
||||
</label>
|
||||
<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'
|
||||
min='0.1'
|
||||
step='0.1'
|
||||
@ -57,7 +57,7 @@ const CommonSettingsSectionCompact: React.FC<CommonSettingsSectionProps> = ({
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -42,7 +42,7 @@ const DescriptionSettingsSectionCompact: React.FC<
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<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
|
||||
</label>
|
||||
<select
|
||||
@ -64,7 +64,7 @@ const DescriptionSettingsSectionCompact: React.FC<
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@ -75,7 +75,7 @@ const DescriptionSettingsSectionCompact: React.FC<
|
||||
</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
|
||||
</label>
|
||||
<textarea
|
||||
@ -87,7 +87,7 @@ const DescriptionSettingsSectionCompact: React.FC<
|
||||
</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)
|
||||
</label>
|
||||
<input
|
||||
@ -101,7 +101,7 @@ const DescriptionSettingsSectionCompact: React.FC<
|
||||
</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)
|
||||
</label>
|
||||
<input
|
||||
@ -115,7 +115,7 @@ const DescriptionSettingsSectionCompact: React.FC<
|
||||
</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
|
||||
</label>
|
||||
<select
|
||||
@ -135,7 +135,7 @@ const DescriptionSettingsSectionCompact: React.FC<
|
||||
</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
|
||||
</label>
|
||||
<select
|
||||
@ -155,7 +155,7 @@ const DescriptionSettingsSectionCompact: React.FC<
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@ -169,7 +169,7 @@ const DescriptionSettingsSectionCompact: React.FC<
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
|
||||
@ -6,22 +6,30 @@
|
||||
*/
|
||||
|
||||
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> = ({
|
||||
values,
|
||||
onChange,
|
||||
}) => {
|
||||
interface EffectsSettingsSectionCompactProps {
|
||||
values: EffectsSettingsFormValues;
|
||||
onChange: (prop: keyof EffectsSettingsFormValues, value: string) => void;
|
||||
elementType?: CanvasElementType;
|
||||
}
|
||||
|
||||
const EffectsSettingsSectionCompact: React.FC<
|
||||
EffectsSettingsSectionCompactProps
|
||||
> = ({ values, onChange, elementType }) => {
|
||||
const showSlideTransition =
|
||||
elementType === 'gallery' || elementType === 'carousel';
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{/* Appear Animation */}
|
||||
<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
|
||||
</p>
|
||||
<div className='grid grid-cols-2 gap-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
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={values.appearAnimation || ''}
|
||||
@ -37,7 +45,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||
<label className='mb-1 block text-[10px] text-white/60'>
|
||||
Duration (sec)
|
||||
</label>
|
||||
<input
|
||||
@ -50,7 +58,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||
<label className='mb-1 block text-[10px] text-white/60'>
|
||||
Easing
|
||||
</label>
|
||||
<select
|
||||
@ -72,12 +80,12 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
|
||||
{/* Hover Effects */}
|
||||
<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
|
||||
</p>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||
<label className='mb-1 block text-[10px] text-white/60'>
|
||||
Scale
|
||||
</label>
|
||||
<input
|
||||
@ -88,7 +96,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||
<label className='mb-1 block text-[10px] text-white/60'>
|
||||
Opacity
|
||||
</label>
|
||||
<input
|
||||
@ -99,7 +107,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||
<label className='mb-1 block text-[10px] text-white/60'>
|
||||
BG color
|
||||
</label>
|
||||
<input
|
||||
@ -110,7 +118,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||
<label className='mb-1 block text-[10px] text-white/60'>
|
||||
Text color
|
||||
</label>
|
||||
<input
|
||||
@ -121,7 +129,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@ -132,7 +140,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<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)
|
||||
</label>
|
||||
<input
|
||||
@ -149,12 +157,12 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
|
||||
{/* Focus Effects */}
|
||||
<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
|
||||
</p>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||
<label className='mb-1 block text-[10px] text-white/60'>
|
||||
Scale
|
||||
</label>
|
||||
<input
|
||||
@ -165,7 +173,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||
<label className='mb-1 block text-[10px] text-white/60'>
|
||||
Opacity
|
||||
</label>
|
||||
<input
|
||||
@ -176,7 +184,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||
<label className='mb-1 block text-[10px] text-white/60'>
|
||||
Outline
|
||||
</label>
|
||||
<input
|
||||
@ -187,7 +195,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||
<label className='mb-1 block text-[10px] text-white/60'>
|
||||
Box shadow
|
||||
</label>
|
||||
<input
|
||||
@ -202,12 +210,12 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
|
||||
{/* Active/Press Effects */}
|
||||
<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
|
||||
</p>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||
<label className='mb-1 block text-[10px] text-white/60'>
|
||||
Scale
|
||||
</label>
|
||||
<input
|
||||
@ -218,7 +226,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-500'>
|
||||
<label className='mb-1 block text-[10px] text-white/60'>
|
||||
Opacity
|
||||
</label>
|
||||
<input
|
||||
@ -229,7 +237,7 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
</label>
|
||||
<input
|
||||
@ -243,6 +251,101 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -42,15 +42,15 @@ export const ElementSettingsTabsCompact: React.FC<ElementSettingsTabsProps> = ({
|
||||
tabs,
|
||||
}) => {
|
||||
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) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
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
|
||||
? '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)}
|
||||
>
|
||||
|
||||
@ -54,13 +54,13 @@ const GalleryCarouselSettingsSectionCompact: React.FC<
|
||||
}) => {
|
||||
return (
|
||||
<div className='mt-3 space-y-2'>
|
||||
<div className='rounded border border-gray-200 p-2 space-y-2'>
|
||||
<p className='text-[11px] font-semibold text-gray-700'>
|
||||
<div className='rounded border border-white/20 p-2 space-y-2'>
|
||||
<p className='text-[11px] font-semibold text-white/90'>
|
||||
Carousel navigation
|
||||
</p>
|
||||
|
||||
{/* 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
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={prevIconUrl}
|
||||
@ -111,7 +111,7 @@ const GalleryCarouselSettingsSectionCompact: React.FC<
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={nextIconUrl}
|
||||
@ -162,7 +162,7 @@ const GalleryCarouselSettingsSectionCompact: React.FC<
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
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
|
||||
reposition.
|
||||
</p>
|
||||
|
||||
@ -41,13 +41,13 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
showTextAlign = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className='rounded border border-gray-200 p-2 space-y-2'>
|
||||
<p className='text-[11px] font-semibold text-gray-700'>{sectionLabel}</p>
|
||||
<div className='rounded border border-white/20 p-2 space-y-2'>
|
||||
<p className='text-[11px] font-semibold text-white/90'>{sectionLabel}</p>
|
||||
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{/* Background Color */}
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
BG color
|
||||
</label>
|
||||
<input
|
||||
@ -63,7 +63,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
{/* Text Color (not for wrapper) */}
|
||||
{prefix !== 'galleryWrapper' && !showTitleStyles && (
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Text color
|
||||
</label>
|
||||
<input
|
||||
@ -77,7 +77,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
|
||||
{/* Padding */}
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Padding
|
||||
</label>
|
||||
<input
|
||||
@ -90,7 +90,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
|
||||
{/* Border Radius */}
|
||||
<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
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={values[`${prefix}BorderRadius`] || ''}
|
||||
@ -101,7 +101,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
|
||||
{/* Border */}
|
||||
<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
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={values[`${prefix}Border`] || ''}
|
||||
@ -113,7 +113,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
{/* Text Alignment (optional) */}
|
||||
{showTextAlign && (
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Align
|
||||
</label>
|
||||
<select
|
||||
@ -131,7 +131,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
{/* Gap (optional) */}
|
||||
{showGap && (
|
||||
<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
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={values[`${prefix}Gap`] || ''}
|
||||
@ -144,7 +144,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
{/* Backdrop Blur (wrapper only) */}
|
||||
{showBlur && (
|
||||
<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
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={values[`${prefix}BackdropBlur`] || ''}
|
||||
@ -159,7 +159,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
{/* Grid Columns (optional) */}
|
||||
{showColumns && (
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Columns
|
||||
</label>
|
||||
<input
|
||||
@ -179,7 +179,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
{/* Font Size (optional) */}
|
||||
{showFont && (
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Font size
|
||||
</label>
|
||||
<input
|
||||
@ -194,7 +194,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
{/* Font Weight (optional) */}
|
||||
{showFont && (
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Font weight
|
||||
</label>
|
||||
<input
|
||||
@ -210,7 +210,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
{/* Font Family (optional - full width) */}
|
||||
{showFont && (
|
||||
<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
|
||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||
value={values[`${prefix}FontFamily`] || ''}
|
||||
@ -229,10 +229,10 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
{/* Header Dimensions (header section only) */}
|
||||
{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>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Width
|
||||
</label>
|
||||
<input
|
||||
@ -243,7 +243,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Height
|
||||
</label>
|
||||
<input
|
||||
@ -254,7 +254,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Min Height
|
||||
</label>
|
||||
<input
|
||||
@ -265,7 +265,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Max Height
|
||||
</label>
|
||||
<input
|
||||
@ -282,10 +282,10 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
{/* Card Aspect Ratio and Min Height (cards only) */}
|
||||
{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>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Aspect Ratio
|
||||
</label>
|
||||
<select
|
||||
@ -303,7 +303,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Min Height
|
||||
</label>
|
||||
<input
|
||||
@ -320,10 +320,10 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
{/* Card Title Styles (cards only) */}
|
||||
{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>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Title color
|
||||
</label>
|
||||
<input
|
||||
@ -336,7 +336,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Title BG
|
||||
</label>
|
||||
<input
|
||||
@ -349,7 +349,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Title size
|
||||
</label>
|
||||
<input
|
||||
@ -362,7 +362,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='mb-1 block text-[10px] text-gray-600'>
|
||||
<label className='mb-1 block text-[10px] text-white/70'>
|
||||
Title weight
|
||||
</label>
|
||||
<input
|
||||
@ -376,7 +376,7 @@ const GallerySectionStyleInputs: React.FC<GallerySectionStyleInputsProps> = ({
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
|
||||
@ -60,8 +60,8 @@ const GallerySettingsSectionCompact: React.FC<
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{/* Header Settings */}
|
||||
<div className='rounded border border-gray-200 p-2 space-y-2'>
|
||||
<p className='text-[11px] font-semibold text-gray-700'>
|
||||
<div className='rounded border border-white/20 p-2 space-y-2'>
|
||||
<p className='text-[11px] font-semibold text-white/90'>
|
||||
Gallery header
|
||||
</p>
|
||||
|
||||
@ -104,9 +104,9 @@ const GallerySettingsSectionCompact: React.FC<
|
||||
</div>
|
||||
|
||||
{/* 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'>
|
||||
<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
|
||||
type='button'
|
||||
className='text-xs text-blue-700 hover:underline'
|
||||
@ -157,7 +157,7 @@ const GallerySettingsSectionCompact: React.FC<
|
||||
))}
|
||||
|
||||
{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.)
|
||||
</p>
|
||||
)}
|
||||
@ -165,7 +165,7 @@ const GallerySettingsSectionCompact: React.FC<
|
||||
|
||||
{/* Gallery Cards */}
|
||||
<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
|
||||
type='button'
|
||||
className='text-xs text-blue-700 hover:underline'
|
||||
@ -178,10 +178,10 @@ const GallerySettingsSectionCompact: React.FC<
|
||||
{galleryCards.map((card, index) => (
|
||||
<div
|
||||
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'>
|
||||
<p className='text-[11px] font-semibold text-gray-700'>
|
||||
<p className='text-[11px] font-semibold text-white/90'>
|
||||
Card {index + 1}
|
||||
</p>
|
||||
<button
|
||||
@ -234,7 +234,7 @@ const GallerySettingsSectionCompact: React.FC<
|
||||
))}
|
||||
|
||||
{galleryCards.length === 0 && (
|
||||
<p className='text-[11px] text-gray-500'>
|
||||
<p className='text-[11px] text-white/60'>
|
||||
No cards yet. Click "+ Add card" to create one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -39,7 +39,7 @@ const MediaSettingsSectionCompact: React.FC<
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<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}
|
||||
</label>
|
||||
<select
|
||||
@ -60,7 +60,7 @@ const MediaSettingsSectionCompact: React.FC<
|
||||
</select>
|
||||
</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
|
||||
type='checkbox'
|
||||
checked={mediaAutoplay}
|
||||
@ -69,7 +69,7 @@ const MediaSettingsSectionCompact: React.FC<
|
||||
Autoplay
|
||||
</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
|
||||
type='checkbox'
|
||||
checked={mediaLoop}
|
||||
@ -79,7 +79,7 @@ const MediaSettingsSectionCompact: React.FC<
|
||||
</label>
|
||||
|
||||
{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
|
||||
type='checkbox'
|
||||
checked={mediaMuted}
|
||||
|
||||
@ -12,9 +12,24 @@ import type {
|
||||
NavigationButtonKind,
|
||||
CanvasElementType,
|
||||
} from '../../types/constructor';
|
||||
import type { TransitionType, EasingFunction } from '../../types/transition';
|
||||
import { addFallbackAssetOption } from '../../lib/constructorHelpers';
|
||||
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<
|
||||
CanvasElementType,
|
||||
'navigation_next' | 'navigation_prev'
|
||||
@ -31,6 +46,11 @@ interface NavigationSettingsSectionCompactProps {
|
||||
transitionVideoUrl: string;
|
||||
transitionReverseMode: 'auto_reverse' | 'separate_video';
|
||||
reverseVideoUrl: string;
|
||||
// CSS transition settings (used when no video is selected)
|
||||
transitionType?: TransitionType | '';
|
||||
transitionDurationMs?: number | '';
|
||||
transitionEasing?: EasingFunction | '';
|
||||
transitionOverlayColor?: string;
|
||||
allowedNavigationTypes: NavigationElementType[];
|
||||
iconAssetOptions: AssetOption[];
|
||||
transitionVideoOptions: AssetOption[];
|
||||
@ -67,6 +87,10 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
transitionVideoUrl,
|
||||
transitionReverseMode,
|
||||
reverseVideoUrl,
|
||||
transitionType = '',
|
||||
transitionDurationMs = '',
|
||||
transitionEasing = '',
|
||||
transitionOverlayColor = '',
|
||||
allowedNavigationTypes,
|
||||
iconAssetOptions,
|
||||
transitionVideoOptions,
|
||||
@ -83,7 +107,7 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<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
|
||||
</label>
|
||||
<select
|
||||
@ -116,7 +140,7 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@ -127,7 +151,7 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
</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>
|
||||
<select
|
||||
@ -147,7 +171,7 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
</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
|
||||
type='checkbox'
|
||||
checked={navDisabled}
|
||||
@ -158,7 +182,7 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
</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
|
||||
</label>
|
||||
<select
|
||||
@ -178,7 +202,7 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
))}
|
||||
</select>
|
||||
{selectedMediaDurationNote && (
|
||||
<p className='mt-1 text-[11px] text-gray-500'>
|
||||
<p className='mt-1 text-[11px] text-white/60'>
|
||||
{selectedMediaDurationNote}
|
||||
</p>
|
||||
)}
|
||||
@ -186,7 +210,7 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
|
||||
{/* Back navigation info text */}
|
||||
{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
|
||||
transition in reverse.
|
||||
</p>
|
||||
@ -196,7 +220,7 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
{currentKind === 'forward' && (
|
||||
<>
|
||||
<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
|
||||
</label>
|
||||
<select
|
||||
@ -219,7 +243,7 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
</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
|
||||
</label>
|
||||
<select
|
||||
@ -241,14 +265,14 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
))}
|
||||
</select>
|
||||
{selectedTransitionDurationNote && (
|
||||
<p className='mt-1 text-[11px] text-gray-500'>
|
||||
<p className='mt-1 text-[11px] text-white/60'>
|
||||
{selectedTransitionDurationNote}
|
||||
</p>
|
||||
)}
|
||||
</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
|
||||
</label>
|
||||
<select
|
||||
@ -274,7 +298,7 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
|
||||
{transitionReverseMode === 'separate_video' && (
|
||||
<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
|
||||
</label>
|
||||
<select
|
||||
@ -298,9 +322,99 @@ const NavigationSettingsSectionCompact: React.FC<
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className='text-[11px] text-gray-500'>
|
||||
Transition duration is set automatically from the selected video.
|
||||
</p>
|
||||
{/* CSS Transition Settings (when no video selected) */}
|
||||
{!transitionVideoUrl && (
|
||||
<>
|
||||
<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 && (
|
||||
<div className='flex gap-2 pt-1'>
|
||||
|
||||
@ -14,12 +14,12 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<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
|
||||
</p>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<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 (%)
|
||||
</label>
|
||||
<input
|
||||
@ -33,7 +33,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</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 (%)
|
||||
</label>
|
||||
<input
|
||||
@ -47,7 +47,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</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 (%)
|
||||
</label>
|
||||
<input
|
||||
@ -60,7 +60,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</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 (%)
|
||||
</label>
|
||||
<input
|
||||
@ -73,7 +73,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</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 (%)
|
||||
</label>
|
||||
<input
|
||||
@ -86,7 +86,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</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 (%)
|
||||
</label>
|
||||
<input
|
||||
@ -99,7 +99,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@ -110,7 +110,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@ -121,7 +121,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</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)
|
||||
</label>
|
||||
<input
|
||||
@ -135,7 +135,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@ -146,7 +146,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@ -156,7 +156,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@ -167,7 +167,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</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)
|
||||
</label>
|
||||
<input
|
||||
@ -181,7 +181,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</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)
|
||||
</label>
|
||||
<input
|
||||
@ -195,7 +195,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@ -206,7 +206,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@ -219,7 +219,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@ -232,7 +232,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<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
|
||||
</label>
|
||||
<select
|
||||
@ -250,7 +250,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
</select>
|
||||
</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
|
||||
</label>
|
||||
<select
|
||||
@ -267,7 +267,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
</select>
|
||||
</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
|
||||
</label>
|
||||
<select
|
||||
@ -285,7 +285,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
</select>
|
||||
</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
|
||||
</label>
|
||||
<select
|
||||
@ -302,7 +302,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
</select>
|
||||
</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
|
||||
</label>
|
||||
<select
|
||||
@ -318,7 +318,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
</select>
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@ -329,7 +329,7 @@ const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
|
||||
/>
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
|
||||
@ -34,7 +34,7 @@ const TooltipSettingsSectionCompact: React.FC<
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<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
|
||||
</label>
|
||||
<select
|
||||
@ -56,7 +56,7 @@ const TooltipSettingsSectionCompact: React.FC<
|
||||
</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
|
||||
</label>
|
||||
<input
|
||||
@ -67,7 +67,7 @@ const TooltipSettingsSectionCompact: React.FC<
|
||||
</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
|
||||
</label>
|
||||
<textarea
|
||||
@ -79,7 +79,7 @@ const TooltipSettingsSectionCompact: React.FC<
|
||||
</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
|
||||
</label>
|
||||
<select
|
||||
@ -99,7 +99,7 @@ const TooltipSettingsSectionCompact: React.FC<
|
||||
</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
|
||||
</label>
|
||||
<select
|
||||
|
||||
@ -53,6 +53,12 @@ export interface EffectsSettingsFormValues {
|
||||
activeScale?: string;
|
||||
activeOpacity?: 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
/**
|
||||
* PreviousBackgroundOverlay Component
|
||||
*
|
||||
* Renders the previous page background during page transitions.
|
||||
* Shows during loading and crossfade, with optional fade-out animation.
|
||||
* Used by both CanvasBackground (constructor) and RuntimePresentation.
|
||||
* Shows the previous page background IMAGE during page transitions
|
||||
* while the new background is loading.
|
||||
*
|
||||
* 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';
|
||||
@ -11,55 +16,44 @@ import React from 'react';
|
||||
interface PreviousBackgroundOverlayProps {
|
||||
/** Previous background image URL */
|
||||
imageUrl?: string;
|
||||
/** Previous background video URL */
|
||||
/** Previous background video URL (kept for interface compatibility, not rendered) */
|
||||
videoUrl?: string;
|
||||
/** Whether page is currently switching */
|
||||
isSwitching?: boolean;
|
||||
/** Whether new background is ready */
|
||||
isNewBgReady?: boolean;
|
||||
/** Whether fade animation is in progress */
|
||||
isFadingIn?: boolean;
|
||||
/** Whether to pause video playback (kept for interface compatibility) */
|
||||
paused?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Fade duration - DEPRECATED, kept for interface compat */
|
||||
fadeDuration?: number;
|
||||
}
|
||||
|
||||
const PreviousBackgroundOverlay: React.FC<PreviousBackgroundOverlayProps> = ({
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
// videoUrl - not used, see docstring
|
||||
isSwitching = false,
|
||||
isNewBgReady = false,
|
||||
isFadingIn = false,
|
||||
// paused - not used, see docstring
|
||||
className = '',
|
||||
// fadeDuration - deprecated, not used
|
||||
}) => {
|
||||
// Show during loading (isSwitching && !isNewBgReady) OR during crossfade (isFadingIn)
|
||||
const shouldShow = isFadingIn || (isSwitching && !isNewBgReady);
|
||||
// Simple render logic: show while switching AND new bg not ready
|
||||
const shouldRender = isSwitching && !isNewBgReady && !!imageUrl;
|
||||
|
||||
if (!shouldShow) return null;
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{imageUrl && (
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-0 z-0 ${isFadingIn ? 'animate-crossfade-out' : ''} ${className}`}
|
||||
style={{
|
||||
backgroundImage: `url("${imageUrl}")`,
|
||||
backgroundSize: 'contain',
|
||||
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
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-0 z-2 ${className}`}
|
||||
style={{
|
||||
backgroundImage: `url("${imageUrl}")`,
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -15,9 +15,9 @@ import {
|
||||
hasAnyEffects,
|
||||
type ElementEffectProperties,
|
||||
} from '../lib/elementEffects';
|
||||
import { isNavigationElementType } from '../lib/elementDefaults';
|
||||
import { isBackNavigation } from '../lib/navigationHelpers';
|
||||
import type { CanvasElement } from '../types/constructor';
|
||||
import type { ResolvedTransitionSettings } from '../types/transition';
|
||||
import type { PreloadCacheProvider } from '../hooks/video';
|
||||
|
||||
interface RuntimeElementProps {
|
||||
element: CanvasElement;
|
||||
@ -28,8 +28,10 @@ interface RuntimeElementProps {
|
||||
onGalleryCardClick?: (cardIndex: number) => void;
|
||||
/** Letterbox styles for constraining fullscreen elements to canvas bounds */
|
||||
letterboxStyles?: React.CSSProperties;
|
||||
/** Whether forward navigation is disabled (neighbor pages not yet preloaded) */
|
||||
isForwardNavDisabled?: boolean;
|
||||
/** Page transition settings (for slide transition cascade in carousel/gallery) */
|
||||
pageTransitionSettings?: ResolvedTransitionSettings;
|
||||
/** Preload cache provider for video elements */
|
||||
preloadCache?: PreloadCacheProvider;
|
||||
}
|
||||
|
||||
// Clamp position to canvas bounds (0-100%)
|
||||
@ -42,7 +44,8 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
||||
resolveUrl,
|
||||
onGalleryCardClick,
|
||||
letterboxStyles,
|
||||
isForwardNavDisabled = false,
|
||||
pageTransitionSettings,
|
||||
preloadCache,
|
||||
}) => {
|
||||
// Clamp coordinates to canvas bounds
|
||||
const xPercent = clamp(element.xPercent ?? 50, 0, 100);
|
||||
@ -102,14 +105,6 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
||||
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 (
|
||||
<div
|
||||
className='absolute cursor-pointer'
|
||||
@ -123,7 +118,8 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
|
||||
resolveUrl={resolveUrl}
|
||||
onGalleryCardClick={onGalleryCardClick}
|
||||
letterboxStyles={letterboxStyles}
|
||||
isDisabled={isDisabled}
|
||||
pageTransitionSettings={pageTransitionSettings}
|
||||
preloadCache={preloadCache}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -14,6 +14,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import CardBox from './CardBox';
|
||||
@ -24,6 +25,8 @@ import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
||||
import { BackdropPortalProvider } from './BackdropPortal';
|
||||
import { RotatePrompt } from './RotatePrompt';
|
||||
import CanvasBackground from './Constructor/CanvasBackground';
|
||||
import CanvasLoadingSpinner from './CanvasLoadingSpinner';
|
||||
import TransitionBlackOverlay from './TransitionBlackOverlay';
|
||||
import { useCanvasScale } from '../hooks/useCanvasScale';
|
||||
import { CANVAS_CONFIG } from '../config/canvas.config';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
@ -36,12 +39,12 @@ import {
|
||||
extractPageLinksOnly,
|
||||
extractElementsForPages,
|
||||
} from '../lib/extractPageLinks';
|
||||
import { usePageSwitch } from '../hooks/usePageSwitch';
|
||||
import { usePageNavigationState } from '../hooks/usePageNavigationState';
|
||||
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
|
||||
import { useBackgroundTransition } from '../hooks/useBackgroundTransition';
|
||||
import { useBackgroundUrls } from '../hooks/useBackgroundUrls';
|
||||
import { useNetworkAware } from '../hooks/useNetworkAware';
|
||||
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 {
|
||||
resolveNavigationTarget,
|
||||
@ -49,8 +52,20 @@ import {
|
||||
isBackNavigation,
|
||||
isNavigationType,
|
||||
} 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 { CanvasElement } from '../types/constructor';
|
||||
import type { ElementTransitionSettings } from '../types/transition';
|
||||
import {
|
||||
entityToProjectSettings,
|
||||
extractElementTransitionSettings,
|
||||
} from '../types/transition';
|
||||
|
||||
interface RuntimePresentationProps {
|
||||
projectSlug: string;
|
||||
@ -61,7 +76,13 @@ export default function RuntimePresentation({
|
||||
projectSlug,
|
||||
environment,
|
||||
}: RuntimePresentationProps) {
|
||||
const dispatch = useAppDispatch();
|
||||
const globalTransitionDefaults = useAppSelector(
|
||||
(state) => state.global_transition_defaults.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(
|
||||
{
|
||||
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
|
||||
const { faviconUrl, ogImageUrl } = useProjectAssets(project);
|
||||
|
||||
@ -105,29 +164,23 @@ export default function RuntimePresentation({
|
||||
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<{
|
||||
targetPageId: string;
|
||||
videoUrl: string;
|
||||
storageKey: string;
|
||||
isBack: boolean;
|
||||
reverseVideoUrl?: string;
|
||||
reverseStorageKey?: string;
|
||||
} | null>(null);
|
||||
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<{
|
||||
element: CanvasElement;
|
||||
initialIndex: number;
|
||||
} | 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 lastInitializedPageIdRef = useRef<string | null>(null);
|
||||
|
||||
@ -175,6 +228,9 @@ export default function RuntimePresentation({
|
||||
}, [pages, pageLinks, selectedPageId]);
|
||||
|
||||
// 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({
|
||||
pages,
|
||||
pageLinks,
|
||||
@ -184,8 +240,15 @@ export default function RuntimePresentation({
|
||||
enabled: !isLoading && !error,
|
||||
});
|
||||
|
||||
// Initialize page switch hook for smooth background transitions
|
||||
const pageSwitch = usePageSwitch({
|
||||
// Selected page - moved early for easier access
|
||||
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
|
||||
? {
|
||||
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
|
||||
@ -193,10 +256,37 @@ export default function RuntimePresentation({
|
||||
preloadedUrls: preloadOrchestrator.preloadedUrls,
|
||||
}
|
||||
: 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)
|
||||
const { isBuffering, phase: transitionPhase } = useTransitionPlayback({
|
||||
const { isBuffering, isVideoReady, phase: transitionPhase } = useTransitionPlayback({
|
||||
videoRef: transitionVideoRef,
|
||||
transition: transitionPreview
|
||||
? {
|
||||
@ -204,33 +294,39 @@ export default function RuntimePresentation({
|
||||
storageKey: transitionPreview.storageKey,
|
||||
reverseMode: transitionPreview.reverseVideoUrl ? 'separate' : 'none',
|
||||
reverseVideoUrl: transitionPreview.reverseVideoUrl,
|
||||
reverseStorageKey: transitionPreview.reverseStorageKey, // Raw path for cache lookup
|
||||
targetPageId: transitionPreview.targetPageId,
|
||||
displayName: 'Transition',
|
||||
isBack: transitionPreview.isBack,
|
||||
}
|
||||
: null,
|
||||
onComplete: async (targetPageId, isBack) => {
|
||||
// Resume background downloads now that transition is complete
|
||||
downloadManager.resumeAll();
|
||||
if (targetPageId) {
|
||||
const targetPage = pages.find((p) => p.id === targetPageId);
|
||||
// Mark this page as initialized to prevent redundant effect calls
|
||||
lastInitializedPageIdRef.current = targetPageId;
|
||||
// Use shared hook to resolve blob URLs and switch page
|
||||
await pageSwitch.switchToPage(targetPage, () => {
|
||||
// Use applyPageSelection for proper history management (pops on back)
|
||||
applyPageSelection(targetPageId, isBack ?? false);
|
||||
// Signal that transition video has ended
|
||||
// State machine transitions to 'transition_done', waiting for background
|
||||
onTransitionEnded();
|
||||
// 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 {
|
||||
// No target page - clean up and remove overlay
|
||||
const video = transitionVideoRef.current;
|
||||
video?.removeAttribute('src');
|
||||
video?.load();
|
||||
setTransitionPreview(null);
|
||||
setPendingTransitionComplete(false);
|
||||
navResetToIdle();
|
||||
}
|
||||
},
|
||||
features: {
|
||||
@ -243,22 +339,30 @@ export default function RuntimePresentation({
|
||||
preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(),
|
||||
getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl,
|
||||
getReadyBlobUrl: preloadOrchestrator?.getReadyBlobUrl,
|
||||
getReadyBlob: preloadOrchestrator?.getReadyBlob,
|
||||
},
|
||||
});
|
||||
|
||||
// Use shared background transition hook for crossfade effects
|
||||
// NOTE: fadeOut config is NOT used for video transitions.
|
||||
// Video transitions end instantly (last frame = new page, then overlay removed).
|
||||
// fadeIn is used for non-video navigation (crossfade 500ms).
|
||||
// hasActiveTransition includes pendingTransitionComplete to prevent crossfade
|
||||
// during the video-to-background handoff phase.
|
||||
const { isFadingIn, resetFadeIn } = useBackgroundTransition({
|
||||
pageSwitch,
|
||||
fadeIn: {
|
||||
hasActiveTransition:
|
||||
Boolean(transitionPreview) || pendingTransitionComplete,
|
||||
},
|
||||
});
|
||||
// Sync transition video buffering state with navigation state machine
|
||||
// This enables unified showSpinner logic in the state machine
|
||||
useEffect(() => {
|
||||
const isTransitionBuffering = Boolean(transitionPreview) && isBuffering;
|
||||
onVideoBufferStateChange(isTransitionBuffering);
|
||||
}, [transitionPreview, isBuffering, onVideoBufferStateChange]);
|
||||
|
||||
// Clean up transition preview when state machine says video overlay should be hidden
|
||||
// showTransitionVideo is true during 'transitioning', 'transition_done', and 'fading_in' phases
|
||||
// During 'fading_in', the overlay fades out (isFadingOut=true), then removed when phase goes to 'idle'
|
||||
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 () => {
|
||||
try {
|
||||
@ -287,11 +391,6 @@ export default function RuntimePresentation({
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
const selectedPage = useMemo(
|
||||
() => pages.find((p) => p.id === selectedPageId) || null,
|
||||
[pages, selectedPageId],
|
||||
);
|
||||
|
||||
const pageElements = useMemo(() => {
|
||||
if (!selectedPage) return [];
|
||||
|
||||
@ -313,84 +412,27 @@ export default function RuntimePresentation({
|
||||
useEffect(() => {
|
||||
if (selectedPage && lastInitializedPageIdRef.current !== selectedPage.id) {
|
||||
// Only initialize when backgrounds are empty (initial load)
|
||||
// navigateToPage handles subsequent navigation by calling switchToPage directly
|
||||
if (!pageSwitch.currentBgImageUrl && !pageSwitch.currentBgVideoUrl) {
|
||||
if (!navCurrentBgImageUrl && !navCurrentBgVideoUrl) {
|
||||
lastInitializedPageIdRef.current = selectedPage.id;
|
||||
pageSwitch.switchToPage(selectedPage);
|
||||
navNavigateToPage(selectedPage);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
selectedPage,
|
||||
pageSwitch.currentBgImageUrl,
|
||||
pageSwitch.currentBgVideoUrl,
|
||||
pageSwitch.switchToPage,
|
||||
navCurrentBgImageUrl,
|
||||
navCurrentBgVideoUrl,
|
||||
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(() => {
|
||||
// Only mark ready immediately if there's no background media at all.
|
||||
// For pages with image or video, CanvasBackground will call onBackgroundReady
|
||||
// after the media's first frame is painted (using scheduleAfterPaint or requestVideoFrameCallback).
|
||||
if (
|
||||
!selectedPage?.background_image_url &&
|
||||
!selectedPage?.background_video_url
|
||||
) {
|
||||
setIsBackgroundReady(true);
|
||||
if (navShowElements && transitionPreview) {
|
||||
// Clear transition preview - overlay will be removed
|
||||
setTransitionPreview(null);
|
||||
}
|
||||
}, [selectedPage?.background_image_url, selectedPage?.background_video_url]);
|
||||
|
||||
// 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]);
|
||||
}, [navShowElements, transitionPreview]);
|
||||
|
||||
const navigateToPage = useCallback(
|
||||
async (
|
||||
@ -402,10 +444,22 @@ export default function RuntimePresentation({
|
||||
const targetPage = pages.find((p) => p.id === targetPageId);
|
||||
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
|
||||
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
|
||||
setTransitionPreview({
|
||||
targetPageId,
|
||||
@ -415,32 +469,64 @@ export default function RuntimePresentation({
|
||||
reverseVideoUrl: reverseVideoUrl
|
||||
? resolveAssetPlaybackUrl(reverseVideoUrl)
|
||||
: undefined,
|
||||
reverseStorageKey: reverseVideoUrl, // Raw storage path for reverse video cache lookup
|
||||
});
|
||||
} else {
|
||||
// Direct navigation with crossfade effect:
|
||||
// useBackgroundTransition detects switching and applies animation classes
|
||||
// - New page gets animate-crossfade-in (0 → 1)
|
||||
// - Previous background gets animate-crossfade-out (1 → 0)
|
||||
setIsBackgroundReady(false);
|
||||
// Direct navigation with fade-from-black effect:
|
||||
// Page switches instantly, black overlay fades out to reveal new page
|
||||
// Mark this page as initialized to prevent redundant effect calls
|
||||
lastInitializedPageIdRef.current = targetPageId;
|
||||
|
||||
await pageSwitch.switchToPage(targetPage, () => {
|
||||
// Use applyPageSelection for proper history management (pops on back)
|
||||
applyPageSelection(targetPageId, isBack);
|
||||
// Log when skipping video due to slow network
|
||||
if (transitionVideoUrl && !shouldUseVideoTransitions) {
|
||||
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
|
||||
const areNeighborBackgroundsReady =
|
||||
preloadOrchestrator?.areNeighborBackgroundsReady ?? true;
|
||||
// Page loading state from unified navigation state machine
|
||||
// navShowSpinner: true when phase is 'preparing', 'loading_bg', or 'transition_done'
|
||||
// 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(
|
||||
(element: CanvasElement) => {
|
||||
@ -451,18 +537,6 @@ export default function RuntimePresentation({
|
||||
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
|
||||
const navContext = getNavigationContext();
|
||||
|
||||
@ -482,6 +556,21 @@ export default function RuntimePresentation({
|
||||
});
|
||||
|
||||
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(
|
||||
navTarget.pageId,
|
||||
navTarget.transitionVideoUrl,
|
||||
@ -496,7 +585,7 @@ export default function RuntimePresentation({
|
||||
transitionPhase,
|
||||
isBuffering,
|
||||
getNavigationContext,
|
||||
areNeighborBackgroundsReady,
|
||||
setCurrentElementTransitionSettings,
|
||||
],
|
||||
);
|
||||
|
||||
@ -528,15 +617,9 @@ export default function RuntimePresentation({
|
||||
[preloadOrchestrator],
|
||||
);
|
||||
|
||||
// Unified background URL resolution via shared hook (same as constructor)
|
||||
// No localPaths needed since RuntimePresentation has no editing mode
|
||||
const {
|
||||
backgroundImageSrc: backgroundImageUrl,
|
||||
backgroundVideoSrc: backgroundVideoUrl,
|
||||
} = useBackgroundUrls({
|
||||
pageSwitch,
|
||||
resolveUrl: resolveUrlWithBlob,
|
||||
});
|
||||
// Background URLs come directly from navigation state (already resolved)
|
||||
const backgroundImageUrl = navCurrentBgImageUrl;
|
||||
const backgroundVideoUrl = navCurrentBgVideoUrl;
|
||||
|
||||
// Background video playback settings from selected page
|
||||
const videoAutoplay = selectedPage?.background_video_autoplay ?? true;
|
||||
@ -664,7 +747,6 @@ export default function RuntimePresentation({
|
||||
<BackdropPortalProvider>
|
||||
{/* Safari Black Flash Prevention (video transitions only):
|
||||
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. */}
|
||||
{lastKnownBgUrl &&
|
||||
isSafari() &&
|
||||
@ -681,76 +763,113 @@ export default function RuntimePresentation({
|
||||
)}
|
||||
|
||||
{/* Page background wrapper - z-5 keeps it BELOW carousel slide (z-10).
|
||||
Fades in for non-transition navigation. Uses shared CanvasBackground component
|
||||
for single source of truth with constructor (same transitions, same structure). */}
|
||||
Uses shared CanvasBackground component for single source of truth with constructor.
|
||||
Previous background overlay shows during loading.
|
||||
Black overlay for fade effect is rendered separately at z-[100]. */}
|
||||
<div
|
||||
data-testid='page-background-wrapper'
|
||||
className={`absolute inset-0 z-5 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
|
||||
className='absolute inset-0 z-5'
|
||||
>
|
||||
<CanvasBackground
|
||||
backgroundImageUrl={backgroundImageUrl}
|
||||
backgroundVideoUrl={backgroundVideoUrl}
|
||||
previousBgImageUrl={pageSwitch.previousBgImageUrl}
|
||||
previousBgVideoUrl={pageSwitch.previousBgVideoUrl}
|
||||
isSwitching={pageSwitch.isSwitching}
|
||||
isNewBgReady={pageSwitch.isNewBgReady}
|
||||
isFadingIn={isFadingIn}
|
||||
onBackgroundReady={() => {
|
||||
setIsBackgroundReady(true);
|
||||
pageSwitch.markBackgroundReady();
|
||||
}}
|
||||
previousBgImageUrl={navPreviousBgImageUrl}
|
||||
previousBgVideoUrl={navPreviousBgVideoUrl}
|
||||
isSwitching={navIsSwitching}
|
||||
isNewBgReady={navIsNewBgReady}
|
||||
onBackgroundReady={navOnBackgroundReady}
|
||||
onVideoBufferStateChange={onVideoBufferStateChange}
|
||||
videoAutoplay={videoAutoplay}
|
||||
videoLoop={videoLoop}
|
||||
videoMuted={soundControl.isMuted}
|
||||
videoStartTime={videoStartTime}
|
||||
videoEndTime={videoEndTime}
|
||||
videoStoragePath={selectedPage?.background_video_url}
|
||||
pauseVideo={
|
||||
Boolean(transitionPreview) ||
|
||||
pendingTransitionComplete ||
|
||||
navIsSwitching
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* 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).
|
||||
UI controls (z-50) remain on top.
|
||||
Fades in together with background. */}
|
||||
<div
|
||||
data-testid='page-elements-wrapper'
|
||||
className={`absolute inset-0 z-[46] ${isFadingIn ? 'animate-crossfade-in' : ''}`}
|
||||
>
|
||||
{pageElements.map((element: CanvasElement) => (
|
||||
<RuntimeElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
onClick={() => handleElementClick(element)}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
onGalleryCardClick={(cardIndex) =>
|
||||
handleGalleryCardClick(element, cardIndex)
|
||||
}
|
||||
letterboxStyles={letterboxStyles}
|
||||
isForwardNavDisabled={isForwardNavDisabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
No fade animation - elements switch instantly behind the black overlay.
|
||||
Shows when phase is 'idle' or 'fading_in' (navShowElements). */}
|
||||
{navShowElements && (
|
||||
<div
|
||||
data-testid='page-elements-wrapper'
|
||||
className='absolute inset-0 z-[46]'
|
||||
>
|
||||
{pageElements.map((element: CanvasElement) => (
|
||||
<RuntimeElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
onClick={() => handleElementClick(element)}
|
||||
resolveUrl={resolveUrlWithBlob}
|
||||
onGalleryCardClick={(cardIndex) =>
|
||||
handleGalleryCardClick(element, cardIndex)
|
||||
}
|
||||
letterboxStyles={letterboxStyles}
|
||||
pageTransitionSettings={transitionSettings}
|
||||
preloadCache={
|
||||
preloadOrchestrator
|
||||
? {
|
||||
getReadyBlob: preloadOrchestrator.getReadyBlob,
|
||||
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 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 */}
|
||||
{/* 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) */}
|
||||
{/* Overlay removed instantly via setTransitionPreview(null) in onComplete */}
|
||||
{transitionPreview && (
|
||||
{/* Fades out during 'fading_in' phase when background is ready */}
|
||||
{/* Overlay stays visible until fade completes (phase goes to 'idle') */}
|
||||
{transitionPreview && showTransitionVideo && (
|
||||
<TransitionPreviewOverlay
|
||||
videoKey={transitionPreview.videoUrl}
|
||||
videoRef={transitionVideoRef}
|
||||
isActive={true}
|
||||
isBuffering={
|
||||
// Hide overlay until video first frame is painted:
|
||||
// Show spinner during buffering:
|
||||
// - 'idle': React render cycle before hook effect runs
|
||||
// - 'preparing': Video loading/buffering
|
||||
// - isBuffering: Waiting for first frame paint (from hook)
|
||||
// - isBuffering: Waiting for first frame or mid-playback buffering
|
||||
transitionPhase === 'idle' ||
|
||||
transitionPhase === 'preparing' ||
|
||||
isBuffering
|
||||
}
|
||||
isVideoReady={isVideoReady}
|
||||
showSpinner={true}
|
||||
letterboxStyles={letterboxStyles}
|
||||
opacity={1}
|
||||
isFadingOut={isFadingIn}
|
||||
fadeOutDuration={transitionSettings.durationMs}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -800,6 +919,8 @@ export default function RuntimePresentation({
|
||||
}
|
||||
letterboxStyles={letterboxStyles}
|
||||
isEditMode={false}
|
||||
pageTransitionSettings={transitionSettings}
|
||||
galleryElement={activeGalleryCarousel.element}
|
||||
/>
|
||||
)}
|
||||
</BackdropPortalProvider>
|
||||
|
||||
@ -4,7 +4,11 @@ import {
|
||||
mdiFileDocumentPlus,
|
||||
mdiSwapHorizontal,
|
||||
mdiViewDashboard,
|
||||
mdiChevronDown,
|
||||
mdiChevronUp,
|
||||
mdiPencil,
|
||||
} from '@mdi/js';
|
||||
import Icon from '@mdi/react';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
@ -17,15 +21,29 @@ import SectionMain from './SectionMain';
|
||||
import SectionTitleLineWithButton from './SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../config';
|
||||
import { hasPermission } from '../helpers/userPermissions';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import { useAppSelector, useAppDispatch } from '../stores/hooks';
|
||||
import { logger } from '../lib/logger';
|
||||
import { sanitizeSlug, buildUniqueSlug, slugPattern } from '../lib/slugHelpers';
|
||||
import { sanitizeSlug, buildUniqueSlug } from '../lib/slugHelpers';
|
||||
import {
|
||||
toRoutePath,
|
||||
compareRoutes,
|
||||
getProjectId,
|
||||
getRows,
|
||||
} 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 = {
|
||||
id: string;
|
||||
@ -61,9 +79,27 @@ type ListEntry = {
|
||||
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 router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const globalDefaults = useAppSelector(
|
||||
(state) => state.global_transition_defaults.data,
|
||||
);
|
||||
|
||||
const routeProjectId = useMemo(() => {
|
||||
const value = router.query.projectId;
|
||||
@ -79,10 +115,45 @@ const TourFlowManager = () => {
|
||||
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
||||
const [isCreatingTransition, setIsCreatingTransition] = useState(false);
|
||||
const [isCreatePageModalActive, setIsCreatePageModalActive] = useState(false);
|
||||
const [newPageSlug, setNewPageSlug] = useState('');
|
||||
const [newPageSlugError, setNewPageSlugError] = useState('');
|
||||
const [newPageName, setNewPageName] = useState('');
|
||||
const [deletingId, setDeletingId] = 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 canCreateTransition = hasPermission(currentUser, 'CREATE_TRANSITIONS');
|
||||
@ -161,6 +232,38 @@ const TourFlowManager = () => {
|
||||
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(() => {
|
||||
if (!selectedProjectId) return;
|
||||
if (routeProjectId && selectedProjectId === routeProjectId) return;
|
||||
@ -240,15 +343,25 @@ const TourFlowManager = () => {
|
||||
[pages, targetEnvironment],
|
||||
);
|
||||
|
||||
const slugValidationError = useMemo(() => {
|
||||
const slug = newPageSlug.trim();
|
||||
if (!slug) return 'Slug is required.';
|
||||
if (!slugPattern.test(slug))
|
||||
return 'Use lowercase letters, numbers, and hyphens only.';
|
||||
if (pageSlugsInEnvironment.has(slug))
|
||||
return 'This slug already exists in the selected environment.';
|
||||
const nameValidationError = useMemo(() => {
|
||||
const name = newPageName.trim();
|
||||
if (!name) return 'Page name is required.';
|
||||
if (name.length > 255) return 'Page name must be 255 characters or less.';
|
||||
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]);
|
||||
|
||||
@ -310,24 +423,113 @@ const TourFlowManager = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestedSlug = buildUniqueSlug(
|
||||
`page-${nextPageNumber}`,
|
||||
pageSlugsInEnvironment,
|
||||
);
|
||||
setNewPageSlug(suggestedSlug);
|
||||
setNewPageSlugError('');
|
||||
const suggestedName = `Page ${nextPageNumber}`;
|
||||
setNewPageName(suggestedName);
|
||||
setIsCreatePageModalActive(true);
|
||||
};
|
||||
|
||||
const handleSlugChange = (value: string) => {
|
||||
setNewPageSlug(value);
|
||||
setNewPageSlugError('');
|
||||
const handleNameChange = (value: string) => {
|
||||
setNewPageName(value);
|
||||
};
|
||||
|
||||
const closeCreatePageModal = () => {
|
||||
setIsCreatePageModalActive(false);
|
||||
setNewPageSlug('');
|
||||
setNewPageSlugError('');
|
||||
setNewPageName('');
|
||||
};
|
||||
|
||||
// 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 () => {
|
||||
@ -336,23 +538,22 @@ const TourFlowManager = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const slug = newPageSlug.trim();
|
||||
const validationError = slugValidationError || newPageSlugError;
|
||||
if (validationError) {
|
||||
setNewPageSlugError(validationError);
|
||||
const name = newPageName.trim();
|
||||
|
||||
// Validate name
|
||||
if (nameValidationError) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCreatingPage(true);
|
||||
setErrorMessage('');
|
||||
setNewPageSlugError('');
|
||||
const payload = {
|
||||
project: activeProjectId,
|
||||
environment: targetEnvironment,
|
||||
source_key: '',
|
||||
name: `Page ${nextPageNumber}`,
|
||||
slug,
|
||||
name,
|
||||
slug: generatedSlug,
|
||||
sort_order:
|
||||
Math.max(...pages.map((item) => Number(item.sort_order || 0)), 0) + 1,
|
||||
background_image_url: '',
|
||||
@ -364,8 +565,7 @@ const TourFlowManager = () => {
|
||||
};
|
||||
|
||||
await axios.post('/tour_pages', { data: payload });
|
||||
setIsCreatePageModalActive(false);
|
||||
setNewPageSlug('');
|
||||
closeCreatePageModal();
|
||||
await loadData();
|
||||
} catch (error: unknown) {
|
||||
const axiosError = error as {
|
||||
@ -376,7 +576,6 @@ const TourFlowManager = () => {
|
||||
(error instanceof Error ? error.message : null) ||
|
||||
'Failed to create page.';
|
||||
setErrorMessage(message);
|
||||
setNewPageSlugError(message);
|
||||
logger.error(
|
||||
'Failed to create page:',
|
||||
error instanceof Error ? error : { error },
|
||||
@ -391,6 +590,57 @@ const TourFlowManager = () => {
|
||||
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 (
|
||||
event: React.MouseEvent,
|
||||
id: string,
|
||||
@ -486,37 +736,208 @@ const TourFlowManager = () => {
|
||||
/>
|
||||
</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 "Save
|
||||
to Stage" and to Production when you "Publish".
|
||||
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
|
||||
title='Create page'
|
||||
buttonColor='info'
|
||||
buttonLabel={isCreatingPage ? 'Creating...' : 'Create'}
|
||||
isConfirmDisabled={Boolean(slugValidationError) || isCreatingPage}
|
||||
isConfirmDisabled={Boolean(nameValidationError) || isCreatingPage}
|
||||
isActive={isCreatePageModalActive}
|
||||
onConfirm={handleCreatePage}
|
||||
onCancel={isCreatingPage ? undefined : closeCreatePageModal}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
htmlFor='new-page-slug'
|
||||
htmlFor='new-page-name'
|
||||
className='block text-sm font-semibold mb-1'
|
||||
>
|
||||
Page slug
|
||||
Page name
|
||||
</label>
|
||||
<input
|
||||
id='new-page-slug'
|
||||
id='new-page-name'
|
||||
type='text'
|
||||
value={newPageSlug}
|
||||
onChange={(event) => handleSlugChange(event.target.value)}
|
||||
placeholder='my-page-slug'
|
||||
value={newPageName}
|
||||
onChange={(event) => handleNameChange(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}
|
||||
/>
|
||||
<p className='text-xs text-gray-500 mt-2'>
|
||||
Use lowercase letters, numbers, and hyphens.
|
||||
</p>
|
||||
{(newPageSlugError || slugValidationError) && (
|
||||
<p className='text-xs text-red-600 mt-2'>
|
||||
{newPageSlugError || slugValidationError}
|
||||
{nameValidationError && (
|
||||
<p className='text-xs text-red-600 mt-1'>
|
||||
{nameValidationError}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
@ -543,6 +964,9 @@ const TourFlowManager = () => {
|
||||
const canDelete =
|
||||
entry.type === 'page' ? canDeletePage : canDeleteTransition;
|
||||
const isDeleting = deletingId === entry.id;
|
||||
const pageData = entry.type === 'page'
|
||||
? pages.find((p) => p.id === entry.id)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<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'>
|
||||
{entry.description}
|
||||
</p>
|
||||
@ -578,17 +1002,29 @@ const TourFlowManager = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BaseButton
|
||||
className='!absolute top-3 right-3'
|
||||
icon={mdiClose}
|
||||
color='danger'
|
||||
outline
|
||||
small
|
||||
onClick={(event) =>
|
||||
handleDelete(event, entry.id, entry.type)
|
||||
}
|
||||
disabled={!canDelete || isDeleting}
|
||||
/>
|
||||
<div className='absolute top-3 right-3 flex gap-1'>
|
||||
{entry.type === 'page' && pageData && (
|
||||
<BaseButton
|
||||
icon={mdiPencil}
|
||||
color='info'
|
||||
outline
|
||||
small
|
||||
onClick={(event) =>
|
||||
openEditPageModal(event, pageData)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<BaseButton
|
||||
icon={mdiClose}
|
||||
color='danger'
|
||||
outline
|
||||
small
|
||||
onClick={(event) =>
|
||||
handleDelete(event, entry.id, entry.type)
|
||||
}
|
||||
disabled={!canDelete || isDeleting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
53
frontend/src/components/TransitionBlackOverlay.tsx
Normal file
53
frontend/src/components/TransitionBlackOverlay.tsx
Normal 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;
|
||||
@ -9,8 +9,14 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import Icon from '@mdi/react';
|
||||
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 {
|
||||
resolveSlideTransition,
|
||||
extractGallerySlideOverride,
|
||||
} from '../../lib/resolveSlideTransition';
|
||||
import { useSlideTransition } from '../../hooks/useSlideTransition';
|
||||
|
||||
interface GalleryCarouselOverlayProps {
|
||||
cards: GalleryCard[];
|
||||
@ -46,6 +52,10 @@ interface GalleryCarouselOverlayProps {
|
||||
) => void;
|
||||
// Letterbox styles for constraining overlay to canvas bounds
|
||||
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) =>
|
||||
@ -75,9 +85,33 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
|
||||
isEditMode = false,
|
||||
onButtonPositionChange,
|
||||
letterboxStyles,
|
||||
pageTransitionSettings,
|
||||
galleryElement,
|
||||
}) => {
|
||||
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<
|
||||
'prev' | 'next' | 'back' | null
|
||||
>(null);
|
||||
@ -111,13 +145,15 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
|
||||
// Navigation handlers
|
||||
const goToPrev = useCallback(() => {
|
||||
if (cards.length === 0) return;
|
||||
setCurrentIndex((prev) => (prev - 1 + cards.length) % cards.length);
|
||||
}, [cards.length]);
|
||||
const newIndex = (displayIndex - 1 + cards.length) % cards.length;
|
||||
goToIndex(newIndex);
|
||||
}, [cards.length, displayIndex, goToIndex]);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
if (cards.length === 0) return;
|
||||
setCurrentIndex((prev) => (prev + 1) % cards.length);
|
||||
}, [cards.length]);
|
||||
const newIndex = (displayIndex + 1) % cards.length;
|
||||
goToIndex(newIndex);
|
||||
}, [cards.length, displayIndex, goToIndex]);
|
||||
|
||||
// Keyboard navigation
|
||||
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) : '';
|
||||
|
||||
return (
|
||||
@ -377,9 +413,21 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
|
||||
src={imageUrl}
|
||||
alt={currentCard?.title || ''}
|
||||
className='absolute inset-0 h-full w-full object-contain'
|
||||
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
|
||||
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 */}
|
||||
{renderNavButton(
|
||||
|
||||
@ -10,6 +10,8 @@
|
||||
|
||||
import React from 'react';
|
||||
import type { CanvasElement } from '../../types/constructor';
|
||||
import type { ResolvedTransitionSettings } from '../../types/transition';
|
||||
import type { PreloadCacheProvider } from '../../hooks/video';
|
||||
import { useElementWrapperStyle } from './shared/useElementWrapperStyle';
|
||||
import {
|
||||
isNavigationElementType,
|
||||
@ -42,7 +44,6 @@ export interface UiElementRendererProps {
|
||||
// Constructor-specific props (optional)
|
||||
isSelected?: boolean;
|
||||
isEditMode?: boolean;
|
||||
isDisabled?: boolean;
|
||||
// Gallery carousel callback
|
||||
onGalleryCardClick?: (cardIndex: number) => void;
|
||||
// Carousel-specific callback for button position changes (constructor only)
|
||||
@ -53,6 +54,10 @@ export interface UiElementRendererProps {
|
||||
) => void;
|
||||
// Letterbox styles for constraining fullscreen elements to canvas bounds
|
||||
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,
|
||||
isSelected = false,
|
||||
isEditMode = false,
|
||||
isDisabled = false,
|
||||
onGalleryCardClick,
|
||||
onCarouselButtonPositionChange,
|
||||
letterboxStyles,
|
||||
pageTransitionSettings,
|
||||
preloadCache,
|
||||
}) => {
|
||||
const { className, style } = useElementWrapperStyle({
|
||||
element,
|
||||
isSelected,
|
||||
isEditMode,
|
||||
isDisabled,
|
||||
});
|
||||
|
||||
// Common props for all element types
|
||||
@ -101,11 +106,12 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
|
||||
isEditMode={isEditMode}
|
||||
onButtonPositionChange={onCarouselButtonPositionChange}
|
||||
letterboxStyles={letterboxStyles}
|
||||
pageTransitionSettings={pageTransitionSettings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isVideoPlayerElementType(element.type)) {
|
||||
return <VideoPlayerElement {...commonProps} />;
|
||||
return <VideoPlayerElement {...commonProps} preloadCache={preloadCache} />;
|
||||
}
|
||||
if (isAudioPlayerElementType(element.type)) {
|
||||
return <AudioPlayerElement {...commonProps} />;
|
||||
|
||||
@ -15,20 +15,26 @@
|
||||
*/
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { CSSProperties } from 'react';
|
||||
import Icon from '@mdi/react';
|
||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||
import type { CanvasElement, CarouselSlide } from '../../../types/constructor';
|
||||
import type { ResolvedTransitionSettings } from '../../../types/transition';
|
||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||
import { getFontByKey, getFontStyle } from '../../../lib/fonts';
|
||||
import { toCU } from '../../../lib/canvasScale';
|
||||
import {
|
||||
resolveSlideTransition,
|
||||
extractCarouselSlideOverride,
|
||||
} from '../../../lib/resolveSlideTransition';
|
||||
import { useSlideTransition } from '../../../hooks/useSlideTransition';
|
||||
|
||||
interface CarouselElementProps {
|
||||
element: CanvasElement;
|
||||
@ -44,6 +50,8 @@ interface CarouselElementProps {
|
||||
) => void;
|
||||
// Letterbox styles for constraining full-width carousel to canvas bounds
|
||||
letterboxStyles?: CSSProperties;
|
||||
// Page transition settings (for slide transition cascade)
|
||||
pageTransitionSettings?: ResolvedTransitionSettings;
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
@ -57,13 +65,31 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
|
||||
isEditMode = false,
|
||||
onButtonPositionChange,
|
||||
letterboxStyles,
|
||||
pageTransitionSettings,
|
||||
}) => {
|
||||
const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
|
||||
const slides: CarouselSlide[] = element.carouselSlides || [];
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const currentSlide = slides[currentIndex] || slides[0];
|
||||
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)
|
||||
const [draggingButton, setDraggingButton] = useState<'prev' | 'next' | null>(
|
||||
null,
|
||||
@ -100,13 +126,15 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
|
||||
// Navigation handlers (no event parameter for keyboard/swipe use)
|
||||
const goToPrev = useCallback(() => {
|
||||
if (slides.length === 0) return;
|
||||
setCurrentIndex((prev) => (prev - 1 + slides.length) % slides.length);
|
||||
}, [slides.length]);
|
||||
const newIndex = (displayIndex - 1 + slides.length) % slides.length;
|
||||
goToIndex(newIndex);
|
||||
}, [slides.length, displayIndex, goToIndex]);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
if (slides.length === 0) return;
|
||||
setCurrentIndex((prev) => (prev + 1) % slides.length);
|
||||
}, [slides.length]);
|
||||
const newIndex = (displayIndex + 1) % slides.length;
|
||||
goToIndex(newIndex);
|
||||
}, [slides.length, displayIndex, goToIndex]);
|
||||
|
||||
// Click handlers for buttons (with event propagation control)
|
||||
const handlePrevClick = useCallback(
|
||||
@ -365,9 +393,21 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
|
||||
src={resolve(currentSlide.imageUrl)}
|
||||
alt={currentSlide.caption || 'Carousel slide'}
|
||||
className='absolute inset-0 w-full h-full object-contain'
|
||||
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
|
||||
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>
|
||||
);
|
||||
@ -459,7 +499,7 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
|
||||
// Normal mode: inline carousel within element dimensions
|
||||
return (
|
||||
<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 */}
|
||||
{currentSlide?.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@ -467,9 +507,21 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
|
||||
src={resolve(currentSlide.imageUrl)}
|
||||
alt={currentSlide.caption || 'Carousel slide'}
|
||||
className='w-full h-full object-cover rounded'
|
||||
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
{/* Transition overlay */}
|
||||
{slideTransition.type === 'fade' && (
|
||||
<div
|
||||
className='absolute inset-0 pointer-events-none rounded'
|
||||
style={{
|
||||
...overlayTransitionStyle,
|
||||
backgroundColor: overlayColor,
|
||||
opacity: overlayOpacity,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
{showNavigation && (
|
||||
|
||||
@ -2,28 +2,43 @@
|
||||
* VideoPlayerElement Component
|
||||
*
|
||||
* 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 type { CSSProperties } from 'react';
|
||||
import type { CanvasElement } from '../../../types/constructor';
|
||||
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
|
||||
import type { PreloadCacheProvider } from '../../../hooks/video';
|
||||
import { useVideoPlayer } from '../../../hooks/video';
|
||||
|
||||
interface VideoPlayerElementProps {
|
||||
element: CanvasElement;
|
||||
resolveUrl?: (url: string | undefined) => string;
|
||||
preloadCache?: PreloadCacheProvider;
|
||||
className: string;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
|
||||
element,
|
||||
resolveUrl,
|
||||
preloadCache,
|
||||
className,
|
||||
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) {
|
||||
return (
|
||||
@ -35,9 +50,16 @@ const VideoPlayerElement: React.FC<VideoPlayerElementProps> = ({
|
||||
|
||||
return (
|
||||
<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
|
||||
className='w-full h-full object-cover rounded'
|
||||
src={resolve(element.mediaUrl)}
|
||||
ref={videoRef}
|
||||
className={`w-full h-full object-cover rounded ${isBuffering ? 'opacity-70' : ''}`}
|
||||
src={resolvedUrl || ''}
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
|
||||
@ -27,8 +27,6 @@ interface UseElementWrapperStyleOptions {
|
||||
isSelected?: boolean;
|
||||
/** Constructor-specific: show edit mode styling */
|
||||
isEditMode?: boolean;
|
||||
/** Constructor-specific: show disabled styling */
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
interface ElementWrapperStyle {
|
||||
@ -47,7 +45,6 @@ export function useElementWrapperStyle({
|
||||
element,
|
||||
isSelected = false,
|
||||
isEditMode = false,
|
||||
isDisabled = false,
|
||||
}: UseElementWrapperStyleOptions): ElementWrapperStyle {
|
||||
return useMemo(() => {
|
||||
// Determine element characteristics
|
||||
@ -100,11 +97,7 @@ export function useElementWrapperStyle({
|
||||
// Flex centering for navigation elements (both icons and text)
|
||||
isNavigationElement ? 'flex items-center justify-center' : '',
|
||||
// Constructor-specific states (only applied when in constructor)
|
||||
isEditMode
|
||||
? 'cursor-move'
|
||||
: isDisabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer',
|
||||
isEditMode ? 'cursor-move' : 'cursor-pointer',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
@ -112,9 +105,16 @@ export function useElementWrapperStyle({
|
||||
// Build inline style from element properties
|
||||
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 {
|
||||
className: classNames,
|
||||
style: { ...paddingStyle, ...inlineStyle },
|
||||
style: finalStyle,
|
||||
};
|
||||
}, [element, isSelected, isEditMode, isDisabled]);
|
||||
}, [element, isSelected, isEditMode]);
|
||||
}
|
||||
|
||||
@ -26,6 +26,7 @@ export const OFFLINE_CONFIG = {
|
||||
projectDownloadComplete: 'project-download-complete',
|
||||
queueUpdate: 'queue-update',
|
||||
blobUrlReady: 'blob-url-ready',
|
||||
streamingReady: 'streaming-ready',
|
||||
},
|
||||
|
||||
// Service worker settings
|
||||
|
||||
@ -1,27 +1,22 @@
|
||||
/**
|
||||
* Preload Configuration
|
||||
*
|
||||
* Centralized configuration for asset preloading, priority weights, and queue settings.
|
||||
*/
|
||||
|
||||
export const PRELOAD_CONFIG = {
|
||||
// Queue settings
|
||||
maxConcurrentDownloads: 3,
|
||||
maxRetries: 3,
|
||||
retryDelayMs: 1000,
|
||||
|
||||
// Size thresholds
|
||||
largeFileThreshold: 5 * 1024 * 1024, // 5MB -> use IndexedDB
|
||||
videoChunkSize: 5 * 1024 * 1024, // 5MB chunks
|
||||
largeFileThreshold: 5 * 1024 * 1024,
|
||||
videoChunkSize: 5 * 1024 * 1024,
|
||||
initialVideoBufferSeconds: 5,
|
||||
|
||||
// Priority weights (higher = load first)
|
||||
priority: {
|
||||
currentPage: 1000,
|
||||
neighborBase: 500,
|
||||
assetType: {
|
||||
transition: 150, // Transitions preloaded for faster start
|
||||
image: 100, // Backgrounds load first
|
||||
transition: 150,
|
||||
image: 100,
|
||||
audio: 50,
|
||||
video: 30,
|
||||
} as Record<string, number>,
|
||||
@ -37,38 +32,39 @@ export const PRELOAD_CONFIG = {
|
||||
maxLinkBonus: 50,
|
||||
},
|
||||
|
||||
// Storage
|
||||
storage: {
|
||||
warningPercent: 80,
|
||||
criticalPercent: 95,
|
||||
minFreeBuffer: 50 * 1024 * 1024, // 50MB
|
||||
minFreeBuffer: 50 * 1024 * 1024,
|
||||
},
|
||||
|
||||
// Auto-cleanup timeouts (from hoboken pattern)
|
||||
autoRemove: {
|
||||
completedMs: 3000,
|
||||
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: {
|
||||
enabled: true,
|
||||
videoMaxBytes: 5 * 1024 * 1024, // 5MB (~5 seconds of video)
|
||||
audioMaxBytes: 512 * 1024, // 512KB (~5 seconds of audio)
|
||||
transitionMaxBytes: 3 * 1024 * 1024, // 3MB (~3 seconds of transition video)
|
||||
videoMaxBytes: 5 * 1024 * 1024,
|
||||
audioMaxBytes: 512 * 1024,
|
||||
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: {
|
||||
// All asset URL fields for preloading extraction
|
||||
all: [
|
||||
'iconUrl',
|
||||
'imageUrl',
|
||||
@ -89,7 +85,6 @@ export const PRELOAD_CONFIG = {
|
||||
'poster',
|
||||
'thumbnail',
|
||||
] as const,
|
||||
// Image-only fields for decode before page switch
|
||||
images: [
|
||||
'iconUrl',
|
||||
'imageUrl',
|
||||
@ -102,9 +97,7 @@ export const PRELOAD_CONFIG = {
|
||||
'galleryCarouselBackIconUrl',
|
||||
'src',
|
||||
] as const,
|
||||
// Nested array fields containing assets
|
||||
nested: ['galleryCards', 'carouselSlides', 'galleryInfoSpans'] as const,
|
||||
// Fields within nested items that contain URLs
|
||||
nestedUrlFields: ['imageUrl', 'videoUrl', 'iconUrl'] as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
35
frontend/src/config/transition.config.ts
Normal file
35
frontend/src/config/transition.config.ts
Normal 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;
|
||||
163
frontend/src/context/PageNavigationContext.tsx
Normal file
163
frontend/src/context/PageNavigationContext.tsx
Normal 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';
|
||||
@ -12,6 +12,15 @@
|
||||
@import '_theme.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 */
|
||||
:root {
|
||||
--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 */
|
||||
/* 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 {
|
||||
/* Explicit initial state prevents flash during animation setup */
|
||||
opacity: 0;
|
||||
@ -117,15 +138,15 @@
|
||||
/* Full animation property for maximum browser compatibility */
|
||||
-webkit-animation-name: page-crossfade-in;
|
||||
animation-name: page-crossfade-in;
|
||||
-webkit-animation-duration: var(--crossfade-duration, 700ms);
|
||||
animation-duration: var(--crossfade-duration, 700ms);
|
||||
-webkit-animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
|
||||
animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
|
||||
-webkit-animation-timing-function: var(
|
||||
--crossfade-easing,
|
||||
cubic-bezier(0.4, 0, 0.2, 1)
|
||||
--transition-easing,
|
||||
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
|
||||
);
|
||||
animation-timing-function: var(
|
||||
--crossfade-easing,
|
||||
cubic-bezier(0.4, 0, 0.2, 1)
|
||||
--transition-easing,
|
||||
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
|
||||
);
|
||||
-webkit-animation-fill-mode: forwards;
|
||||
animation-fill-mode: forwards;
|
||||
@ -146,15 +167,15 @@
|
||||
/* Full animation property for maximum browser compatibility */
|
||||
-webkit-animation-name: page-crossfade-out;
|
||||
animation-name: page-crossfade-out;
|
||||
-webkit-animation-duration: var(--crossfade-duration, 700ms);
|
||||
animation-duration: var(--crossfade-duration, 700ms);
|
||||
-webkit-animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
|
||||
animation-duration: var(--transition-duration, var(--crossfade-duration, 700ms));
|
||||
-webkit-animation-timing-function: var(
|
||||
--crossfade-easing,
|
||||
cubic-bezier(0.4, 0, 0.2, 1)
|
||||
--transition-easing,
|
||||
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
|
||||
);
|
||||
animation-timing-function: var(
|
||||
--crossfade-easing,
|
||||
cubic-bezier(0.4, 0, 0.2, 1)
|
||||
--transition-easing,
|
||||
var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1))
|
||||
);
|
||||
-webkit-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) */
|
||||
/* Use this for better Safari stability - transitions don't have the "snap" issue
|
||||
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 */
|
||||
@-webkit-keyframes element-fade-in {
|
||||
from {
|
||||
|
||||
@ -20,12 +20,14 @@ export type {
|
||||
UsePageNavigationOptions,
|
||||
UsePageNavigationResult,
|
||||
} from './usePageNavigation';
|
||||
export { useBackgroundTransition } from './useBackgroundTransition';
|
||||
export { usePageNavigationState } from './usePageNavigationState';
|
||||
export type {
|
||||
FadeOutConfig,
|
||||
UseBackgroundTransitionOptions,
|
||||
UseBackgroundTransitionResult,
|
||||
} from './useBackgroundTransition';
|
||||
NavigationPhase,
|
||||
NavigablePage as NavStatePage,
|
||||
PreloadCacheProvider,
|
||||
UsePageNavigationStateOptions,
|
||||
UsePageNavigationStateResult,
|
||||
} from './usePageNavigationState';
|
||||
export { useBackgroundVideoPlayback } from './useBackgroundVideoPlayback';
|
||||
export type {
|
||||
UseBackgroundVideoPlaybackOptions,
|
||||
@ -54,6 +56,7 @@ export type {
|
||||
UseTransitionCreationOptions,
|
||||
UseTransitionCreationResult,
|
||||
} from './useTransitionCreation';
|
||||
export { useSlideTransition } from './useSlideTransition';
|
||||
|
||||
// Constructor hooks - import directly for better tree-shaking:
|
||||
// import { useOutsideClick } from '../hooks/useOutsideClick';
|
||||
@ -65,3 +68,7 @@ export type {
|
||||
// import { useTransitionPreview } from '../hooks/useTransitionPreview';
|
||||
// import { useConstructorElements } from '../hooks/useConstructorElements';
|
||||
// import { useConstructorPageActions } from '../hooks/useConstructorPageActions';
|
||||
|
||||
// Video primitives - for building custom video playback hooks:
|
||||
// import { useVideoEventManager, useVideoBufferingState, ... } from './video';
|
||||
export * from './video';
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
* useBackgroundVideoPlayback Hook
|
||||
*
|
||||
* 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
|
||||
* 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 { logger } from '../lib/logger';
|
||||
import { useVideoEventManager } from './video/useVideoEventManager';
|
||||
|
||||
// Session-scoped tracking of videos that have finished playing (when loop=false)
|
||||
// Key: videoUrl, cleared on browser refresh
|
||||
@ -30,6 +31,8 @@ export interface UseBackgroundVideoPlaybackOptions {
|
||||
startTime?: number | null;
|
||||
/** End time in seconds (default: null = play to end) */
|
||||
endTime?: number | null;
|
||||
/** External pause control (e.g., during page transitions). Takes precedence over autoplay. */
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
export interface UseBackgroundVideoPlaybackResult {
|
||||
@ -66,6 +69,7 @@ export function useBackgroundVideoPlayback({
|
||||
muted = true,
|
||||
startTime = null,
|
||||
endTime = null,
|
||||
paused = false,
|
||||
}: UseBackgroundVideoPlaybackOptions): UseBackgroundVideoPlaybackResult {
|
||||
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)
|
||||
const shouldBlockAutoplay =
|
||||
!loop && trackingKey ? playedVideos.has(trackingKey) : false;
|
||||
|
||||
// Store current values in refs for event handlers to access
|
||||
const startTimeRef = useRef(startTime);
|
||||
const endTimeRef = useRef(endTime);
|
||||
const loopRef = useRef(loop);
|
||||
const autoplayRef = useRef(autoplay);
|
||||
const pausedRef = useRef(paused);
|
||||
const trackingKeyRef = useRef(trackingKey);
|
||||
|
||||
// Update refs when values change
|
||||
useEffect(() => {
|
||||
startTimeRef.current = startTime;
|
||||
}, [startTime]);
|
||||
|
||||
useEffect(() => {
|
||||
endTimeRef.current = endTime;
|
||||
}, [endTime]);
|
||||
|
||||
useEffect(() => {
|
||||
loopRef.current = loop;
|
||||
}, [loop]);
|
||||
pausedRef.current = paused;
|
||||
trackingKeyRef.current = trackingKey;
|
||||
}, [startTime, endTime, loop, paused, trackingKey]);
|
||||
|
||||
useEffect(() => {
|
||||
autoplayRef.current = autoplay;
|
||||
}, [autoplay]);
|
||||
|
||||
// Seek to start time when specified and video is ready
|
||||
const seekToStartTime = useCallback(() => {
|
||||
// Seek to start time when video metadata is loaded
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
const st = startTimeRef.current;
|
||||
if (!video || st == null || st <= 0) return;
|
||||
@ -109,6 +107,57 @@ export function useBackgroundVideoPlayback({
|
||||
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
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
@ -119,23 +168,19 @@ export function useBackgroundVideoPlayback({
|
||||
video.currentTime = startTime;
|
||||
logger.info('Background video: seeking to start time', { startTime });
|
||||
}
|
||||
}, [videoUrl, startTime]);
|
||||
|
||||
// Set up listener for initial load (if not loaded yet)
|
||||
const handleLoadedMetadata = () => {
|
||||
seekToStartTime();
|
||||
};
|
||||
|
||||
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
};
|
||||
}, [videoUrl, startTime, seekToStartTime]);
|
||||
|
||||
// Handle autoplay state changes
|
||||
// Handle autoplay state changes (respects external pause control)
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !videoUrl) return;
|
||||
|
||||
// External pause takes precedence over autoplay
|
||||
if (paused) {
|
||||
video.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoplay) {
|
||||
video.play().catch((error) => {
|
||||
// Autoplay blocked by browser - this is expected behavior
|
||||
@ -146,46 +191,7 @@ export function useBackgroundVideoPlayback({
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [videoUrl, autoplay]);
|
||||
|
||||
// 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]);
|
||||
}, [videoUrl, autoplay, paused]);
|
||||
|
||||
// Handle muted state changes
|
||||
useEffect(() => {
|
||||
@ -214,13 +220,6 @@ export function useBackgroundVideoPlayback({
|
||||
}
|
||||
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]);
|
||||
|
||||
return { videoRef, shouldBlockAutoplay };
|
||||
|
||||
@ -28,7 +28,7 @@ const EMPTY_PAGES: TourPage[] = [];
|
||||
const EMPTY_ASSETS: Asset[] = [];
|
||||
|
||||
interface UseConstructorDataResult {
|
||||
// Project
|
||||
// Project (note: transition_settings now fetched separately via project_transition_settings store)
|
||||
project: {
|
||||
name: string;
|
||||
design_width?: number;
|
||||
|
||||
@ -80,8 +80,8 @@ interface UseConstructorPageActionsResult {
|
||||
saveConstructor: () => Promise<void>;
|
||||
/** Save dev content to stage environment */
|
||||
saveToStage: () => Promise<void>;
|
||||
/** Create a new page */
|
||||
createPage: () => Promise<void>;
|
||||
/** Create a new page with the given name and slug */
|
||||
createPage: (pageName: string, slug: string) => Promise<void>;
|
||||
/** Create a transition (legacy - transitions are now stored on elements) */
|
||||
createTransition: (params: {
|
||||
name?: string;
|
||||
@ -256,31 +256,40 @@ export function useConstructorPageActions({
|
||||
}
|
||||
}, [projectId, saveConstructor, onError, onSuccess]);
|
||||
|
||||
const createPage = useCallback(async () => {
|
||||
const createPage = useCallback(async (pageName: string, slug: string) => {
|
||||
if (!projectId) {
|
||||
onError?.('Project is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pageName.trim()) {
|
||||
onError?.('Page name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!slug.trim()) {
|
||||
onError?.('Page slug is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const maxSortOrder = Math.max(
|
||||
0,
|
||||
...pages.map((item) => Number(item.sort_order || 0)),
|
||||
);
|
||||
const nextPageNumber = pages.length + 1;
|
||||
|
||||
const payload = {
|
||||
project: projectId,
|
||||
environment: activePage?.environment || 'dev',
|
||||
source_key: '',
|
||||
name: `Page ${nextPageNumber}`,
|
||||
slug: `page-${nextPageNumber}-${Date.now().toString().slice(-4)}`,
|
||||
name: pageName.trim(),
|
||||
slug: slug.trim(),
|
||||
sort_order: maxSortOrder + 1,
|
||||
background_image_url: '',
|
||||
background_video_url: '',
|
||||
background_audio_url: '',
|
||||
background_loop: false,
|
||||
requires_auth: false,
|
||||
ui_schema_json: JSON.stringify({ elements: [] }),
|
||||
ui_schema_json: { elements: [] },
|
||||
// Copy project design dimensions to new page
|
||||
design_width: project?.design_width ?? null,
|
||||
design_height: project?.design_height ?? null,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -1,14 +1,12 @@
|
||||
/**
|
||||
* useNetworkAware Hook
|
||||
*
|
||||
* Monitors network conditions and adapts preloading strategy accordingly.
|
||||
* Uses the Network Information API where available.
|
||||
* Monitors network conditions and adapts behavior accordingly.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { NetworkInfo } from '../types/offline';
|
||||
|
||||
// Extend Navigator interface for Network Information API
|
||||
interface NetworkInformation extends EventTarget {
|
||||
readonly effectiveType?: 'slow-2g' | '2g' | '3g' | '4g';
|
||||
readonly downlink?: number;
|
||||
@ -25,22 +23,11 @@ interface NavigatorWithConnection extends Navigator {
|
||||
|
||||
interface UseNetworkAwareResult {
|
||||
networkInfo: NetworkInfo;
|
||||
/**
|
||||
* Whether preloading should be aggressive (good connection)
|
||||
*/
|
||||
shouldPreloadAggressively: boolean;
|
||||
/**
|
||||
* Whether to prefer lower quality variants
|
||||
*/
|
||||
preferLowQuality: boolean;
|
||||
/**
|
||||
* Recommended concurrent download count based on network
|
||||
*/
|
||||
recommendedConcurrency: number;
|
||||
/**
|
||||
* Whether offline mode should be suggested to user
|
||||
*/
|
||||
suggestOfflineMode: boolean;
|
||||
shouldUseVideoTransitions: boolean;
|
||||
}
|
||||
|
||||
const getConnection = (): NetworkInformation | null => {
|
||||
@ -68,7 +55,6 @@ const getNetworkInfo = (): NetworkInfo => {
|
||||
export function useNetworkAware(): UseNetworkAwareResult {
|
||||
const [networkInfo, setNetworkInfo] = useState<NetworkInfo>(getNetworkInfo);
|
||||
|
||||
// Update network info on changes
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
@ -76,11 +62,9 @@ export function useNetworkAware(): UseNetworkAwareResult {
|
||||
setNetworkInfo(getNetworkInfo());
|
||||
};
|
||||
|
||||
// Listen for online/offline events
|
||||
window.addEventListener('online', updateNetworkInfo);
|
||||
window.addEventListener('offline', updateNetworkInfo);
|
||||
|
||||
// Listen for connection changes if available
|
||||
const connection = getConnection();
|
||||
if (connection) {
|
||||
connection.addEventListener('change', updateNetworkInfo);
|
||||
@ -95,36 +79,28 @@ export function useNetworkAware(): UseNetworkAwareResult {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Determine if preloading should be aggressive
|
||||
const shouldPreloadAggressively = useCallback((): boolean => {
|
||||
if (!networkInfo.isOnline) return false;
|
||||
if (networkInfo.saveData) return false;
|
||||
|
||||
// Good connection: 4g or high downlink
|
||||
if (networkInfo.effectiveType === '4g') return true;
|
||||
if (networkInfo.downlink && networkInfo.downlink >= 5) return true;
|
||||
|
||||
return false;
|
||||
}, [networkInfo]);
|
||||
|
||||
// Determine if low quality variants should be preferred
|
||||
const preferLowQuality = useCallback((): boolean => {
|
||||
if (networkInfo.saveData) return true;
|
||||
if (networkInfo.effectiveType === 'slow-2g') return true;
|
||||
if (networkInfo.effectiveType === '2g') return true;
|
||||
if (networkInfo.downlink && networkInfo.downlink < 1) return true;
|
||||
|
||||
return false;
|
||||
}, [networkInfo]);
|
||||
|
||||
// Calculate recommended concurrency
|
||||
const getRecommendedConcurrency = useCallback((): number => {
|
||||
if (!networkInfo.isOnline) return 0;
|
||||
if (networkInfo.saveData) return 1;
|
||||
|
||||
switch (networkInfo.effectiveType) {
|
||||
case 'slow-2g':
|
||||
return 1;
|
||||
case '2g':
|
||||
return 1;
|
||||
case '3g':
|
||||
@ -132,32 +108,40 @@ export function useNetworkAware(): UseNetworkAwareResult {
|
||||
case '4g':
|
||||
return 3;
|
||||
default:
|
||||
// Fall back to downlink-based calculation
|
||||
if (networkInfo.downlink) {
|
||||
if (networkInfo.downlink < 1) return 1;
|
||||
if (networkInfo.downlink < 5) return 2;
|
||||
return 3;
|
||||
}
|
||||
return 2; // Default
|
||||
return 2;
|
||||
}
|
||||
}, [networkInfo]);
|
||||
|
||||
// Determine if offline mode should be suggested
|
||||
const suggestOfflineMode = useCallback((): boolean => {
|
||||
// Suggest offline if on poor connection
|
||||
if (networkInfo.effectiveType === 'slow-2g') return true;
|
||||
if (networkInfo.effectiveType === '2g') return true;
|
||||
if (networkInfo.rtt && networkInfo.rtt > 500) return true;
|
||||
if (networkInfo.downlink && networkInfo.downlink < 0.5) return true;
|
||||
|
||||
return false;
|
||||
}, [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 {
|
||||
networkInfo,
|
||||
shouldPreloadAggressively: shouldPreloadAggressively(),
|
||||
preferLowQuality: preferLowQuality(),
|
||||
recommendedConcurrency: getRecommendedConcurrency(),
|
||||
suggestOfflineMode: suggestOfflineMode(),
|
||||
shouldUseVideoTransitions: shouldUseVideoTransitions(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -420,7 +420,6 @@ export function useOfflineMode(
|
||||
? 50
|
||||
: 75,
|
||||
storageKey: asset.storageKey,
|
||||
createBlobUrl: true, // Create blob URL for instant display
|
||||
persist: true, // Persist for resume after page refresh
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
867
frontend/src/hooks/usePageNavigationState.ts
Normal file
867
frontend/src/hooks/usePageNavigationState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -1,13 +1,13 @@
|
||||
/**
|
||||
* usePreloadOrchestrator Hook
|
||||
*
|
||||
* Main coordinator for online mode asset preloading.
|
||||
* Manages the priority queue and orchestrates downloads based on navigation.
|
||||
* Coordinates asset preloading based on navigation.
|
||||
* Preloads current page assets and outgoing transition videos.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||
import { useNeighborGraph } from './useNeighborGraph';
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useNetworkAware } from './useNetworkAware';
|
||||
import { extractElementAssets } from '../lib/assetCache';
|
||||
import { downloadEventBus } from '../lib/offline/DownloadEventBus';
|
||||
import { downloadManager } from '../lib/offline/DownloadManager';
|
||||
import { StorageManager } from '../lib/offline/StorageManager';
|
||||
@ -36,44 +36,44 @@ interface UsePreloadOrchestratorOptions {
|
||||
currentPageId: string | null;
|
||||
pageHistory?: string[];
|
||||
enabled?: boolean;
|
||||
maxNeighborDepth?: number;
|
||||
}
|
||||
|
||||
interface PreloadQueueItem {
|
||||
id: string;
|
||||
url: string;
|
||||
storageKey?: string; // Original storage key for presigned URL cache invalidation
|
||||
storageKey?: string;
|
||||
priority: number;
|
||||
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other';
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export type PreloadPhase =
|
||||
| 'idle'
|
||||
| 'phase1_current_page'
|
||||
| 'phase2_transitions'
|
||||
| 'complete';
|
||||
|
||||
interface UsePreloadOrchestratorResult {
|
||||
isPreloading: boolean;
|
||||
preloadedUrls: Set<string>;
|
||||
queueLength: number;
|
||||
/** Version counter that increments when blob URLs become ready (triggers re-renders) */
|
||||
readyUrlsVersion: number;
|
||||
preloadAsset: (url: string, priority?: number) => void;
|
||||
clearQueue: () => void;
|
||||
getCachedBlobUrl: (url: string) => Promise<string | null>;
|
||||
isUrlPreloaded: (url: string) => Promise<boolean>;
|
||||
/** Instant lookup - returns decoded blob URL or null */
|
||||
getReadyBlobUrl: (url: string) => string | null;
|
||||
/** Whether all neighbor page backgrounds are ready for instant navigation */
|
||||
areNeighborBackgroundsReady: boolean;
|
||||
getReadyBlob: (url: string) => Blob | null;
|
||||
currentPhase: PreloadPhase;
|
||||
phaseProgress: number;
|
||||
isCurrentPageReady: boolean;
|
||||
areTransitionsReady: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for preload jobs
|
||||
*/
|
||||
const generateJobId = (): string => {
|
||||
return `preload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Map asset type string to AssetType enum expected by DownloadManager
|
||||
*/
|
||||
const mapAssetType = (
|
||||
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other',
|
||||
): 'image' | 'video' | 'audio' | 'transition' | 'other' => {
|
||||
@ -83,79 +83,28 @@ const mapAssetType = (
|
||||
export function usePreloadOrchestrator(
|
||||
options: UsePreloadOrchestratorOptions,
|
||||
): UsePreloadOrchestratorResult {
|
||||
const {
|
||||
pages,
|
||||
pageLinks,
|
||||
elements,
|
||||
currentPageId,
|
||||
enabled = true,
|
||||
maxNeighborDepth = 1, // Only preload immediate neighbors by default
|
||||
} = options;
|
||||
const { pages, pageLinks, elements, currentPageId, enabled = true } = options;
|
||||
|
||||
const [isPreloading, setIsPreloading] = useState(false);
|
||||
const [preloadedUrls] = useState(() => new Set<string>());
|
||||
const [queueLength, setQueueLength] = useState(0);
|
||||
// Version counter to trigger re-renders when blob URLs become ready
|
||||
const [readyUrlsVersion, setReadyUrlsVersion] = useState(0);
|
||||
const [currentPhase, setCurrentPhase] = useState<PreloadPhase>('idle');
|
||||
const [phaseProgress, setPhaseProgress] = useState(0);
|
||||
|
||||
const queueRef = useRef<PreloadQueueItem[]>([]);
|
||||
const isProcessingRef = useRef(false);
|
||||
const lastPreloadedPageRef = useRef<string | null>(null);
|
||||
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();
|
||||
|
||||
// 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(() => {
|
||||
const unsubscribe = downloadEventBus.on(
|
||||
OFFLINE_CONFIG.events.blobUrlReady as Parameters<
|
||||
typeof downloadEventBus.on
|
||||
>[0],
|
||||
(data: BlobUrlReadyEvent) => {
|
||||
logger.info('[PRELOAD] Blob URL ready from DownloadManager', {
|
||||
storageKey: data.storageKey.slice(-50),
|
||||
});
|
||||
preloadedUrls.add(data.storageKey);
|
||||
setReadyUrlsVersion((v) => v + 1);
|
||||
},
|
||||
@ -163,14 +112,12 @@ export function usePreloadOrchestrator(
|
||||
return unsubscribe;
|
||||
}, [preloadedUrls]);
|
||||
|
||||
// Cleanup blob URLs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
downloadManager.clearBlobUrls();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Process the queue using DownloadManager
|
||||
const processQueue = useCallback(async () => {
|
||||
if (isProcessingRef.current) return;
|
||||
if (!networkInfo.isOnline) return;
|
||||
@ -182,42 +129,29 @@ export function usePreloadOrchestrator(
|
||||
isProcessingRef.current = true;
|
||||
setIsPreloading(true);
|
||||
|
||||
// Process all items in queue
|
||||
while (queueRef.current.length > 0) {
|
||||
const item = queueRef.current.shift();
|
||||
if (!item) break;
|
||||
|
||||
setQueueLength(queueRef.current.length);
|
||||
|
||||
// Get canonical storage key
|
||||
const storageKey = item.storageKey || extractStoragePath(item.url);
|
||||
|
||||
// Skip if already preloaded
|
||||
if (preloadedUrls.has(storageKey)) {
|
||||
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
|
||||
.addJob({
|
||||
assetId: item.id,
|
||||
projectId: '', // Not needed for online preload
|
||||
projectId: '',
|
||||
url: item.url,
|
||||
filename: item.url.split('/').pop() || 'asset',
|
||||
variantType: 'original',
|
||||
assetType: mapAssetType(item.assetType),
|
||||
priority: item.priority,
|
||||
storageKey,
|
||||
createBlobUrl: true, // Create blob URL for instant display
|
||||
persist: false, // Don't persist for online preload (in-memory only)
|
||||
persist: false,
|
||||
})
|
||||
.then(() => {
|
||||
if (isPresignedUrl(item.url)) {
|
||||
@ -238,12 +172,10 @@ export function usePreloadOrchestrator(
|
||||
isProcessingRef.current = false;
|
||||
}, [networkInfo.isOnline, preloadedUrls]);
|
||||
|
||||
// Add item to queue with priority sorting
|
||||
const addToQueue = useCallback(
|
||||
(item: PreloadQueueItem) => {
|
||||
const storageKey = item.storageKey || extractStoragePath(item.url);
|
||||
|
||||
// Skip if already in queue or preloaded
|
||||
if (
|
||||
preloadedUrls.has(storageKey) ||
|
||||
queueRef.current.some(
|
||||
@ -253,15 +185,6 @@ export function usePreloadOrchestrator(
|
||||
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(
|
||||
(q) => q.priority < item.priority,
|
||||
);
|
||||
@ -278,7 +201,6 @@ export function usePreloadOrchestrator(
|
||||
[preloadedUrls, processQueue],
|
||||
);
|
||||
|
||||
// Manual preload function
|
||||
const preloadAsset = useCallback(
|
||||
(url: string, priority = 100) => {
|
||||
addToQueue({
|
||||
@ -292,14 +214,11 @@ export function usePreloadOrchestrator(
|
||||
[addToQueue, currentPageId],
|
||||
);
|
||||
|
||||
// Clear queue
|
||||
const clearQueue = useCallback(() => {
|
||||
queueRef.current = [];
|
||||
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(
|
||||
async (url: string): Promise<string | null> => {
|
||||
try {
|
||||
@ -315,27 +234,24 @@ export function usePreloadOrchestrator(
|
||||
[],
|
||||
);
|
||||
|
||||
// Check if URL is preloaded (in cache)
|
||||
const isUrlPreloaded = useCallback(
|
||||
async (url: string): Promise<boolean> => {
|
||||
const storageKey = extractStoragePath(url);
|
||||
// First check in-memory set
|
||||
if (preloadedUrls.has(storageKey)) return true;
|
||||
|
||||
// Then check via StorageManager
|
||||
return StorageManager.hasAsset(storageKey);
|
||||
},
|
||||
[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 => {
|
||||
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
|
||||
// This ensures getReadyBlobUrl works on the first render for both backgrounds and element icons
|
||||
useEffect(() => {
|
||||
if (!currentPageId) return;
|
||||
|
||||
@ -343,14 +259,12 @@ export function usePreloadOrchestrator(
|
||||
if (!currentPage) return;
|
||||
|
||||
const initializeFromCache = async () => {
|
||||
// Collect background URLs
|
||||
const bgUrls = [
|
||||
currentPage.background_image_url,
|
||||
currentPage.background_video_url,
|
||||
currentPage.background_audio_url,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
// Collect element asset URLs (icons, images, etc.) from current page
|
||||
const currentPageElements = elements.filter(
|
||||
(el) => el.pageId === currentPageId,
|
||||
);
|
||||
@ -363,7 +277,6 @@ export function usePreloadOrchestrator(
|
||||
? JSON.parse(element.content_json)
|
||||
: element.content_json;
|
||||
|
||||
// Extract URLs from known asset fields
|
||||
const urlFields = PRELOAD_CONFIG.assetFields.all as readonly string[];
|
||||
const checkObject = (obj: Record<string, unknown>) => {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
@ -385,20 +298,16 @@ export function usePreloadOrchestrator(
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize all URLs from cache via DownloadManager
|
||||
const allUrls = [...bgUrls, ...elementAssetUrls];
|
||||
|
||||
for (const storagePath of allUrls) {
|
||||
const storageKey = extractStoragePath(storagePath);
|
||||
|
||||
// Skip if already ready
|
||||
if (downloadManager.getReadyBlobUrl(storageKey)) continue;
|
||||
|
||||
// Check if cached and create blob URL if so
|
||||
const fullUrl = resolveAssetPlaybackUrl(storagePath);
|
||||
const hasAsset = await StorageManager.hasAsset(storageKey);
|
||||
if (hasAsset) {
|
||||
// Use DownloadManager.addJob with createBlobUrl to create the blob URL
|
||||
await downloadManager.addJob({
|
||||
assetId: `init-${storageKey}`,
|
||||
projectId: '',
|
||||
@ -407,7 +316,6 @@ export function usePreloadOrchestrator(
|
||||
variantType: 'original',
|
||||
assetType: 'other',
|
||||
storageKey,
|
||||
createBlobUrl: true,
|
||||
persist: false,
|
||||
});
|
||||
}
|
||||
@ -417,14 +325,12 @@ export function usePreloadOrchestrator(
|
||||
initializeFromCache();
|
||||
}, [currentPageId, pages, elements]);
|
||||
|
||||
// React to page changes - preload neighbors
|
||||
// React to page changes - preload current page assets and transitions
|
||||
useEffect(() => {
|
||||
if (!enabled || !currentPageId || !networkInfo.isOnline) {
|
||||
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 samePageAndData =
|
||||
lastPreloadedPageRef.current === currentPageId &&
|
||||
@ -436,26 +342,17 @@ export function usePreloadOrchestrator(
|
||||
lastPreloadedPageRef.current = currentPageId;
|
||||
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);
|
||||
|
||||
// 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 (
|
||||
currentPage?.background_image_url &&
|
||||
isRelativeStoragePath(currentPage.background_image_url)
|
||||
@ -475,92 +372,49 @@ export function usePreloadOrchestrator(
|
||||
storagePaths.push(currentPage.background_audio_url);
|
||||
}
|
||||
|
||||
assets.forEach((asset) => {
|
||||
if (isRelativeStoragePath(asset.url)) {
|
||||
storagePaths.push(asset.url);
|
||||
elementAssets.forEach((asset) => {
|
||||
if (isRelativeStoragePath(asset.storageKey)) {
|
||||
storagePaths.push(asset.storageKey);
|
||||
}
|
||||
});
|
||||
|
||||
// Always collect neighbor background URLs for presigning
|
||||
// This ensures instant navigation to neighbor pages
|
||||
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
|
||||
neighbors.forEach(({ pageId }) => {
|
||||
const page = pages.find((p) => p.id === pageId);
|
||||
if (
|
||||
page?.background_image_url &&
|
||||
isRelativeStoragePath(page.background_image_url)
|
||||
) {
|
||||
storagePaths.push(page.background_image_url);
|
||||
// Add outgoing transition video URLs (forward and reverse)
|
||||
// Reverse videos are preloaded here so they're cached when user navigates and clicks back
|
||||
const outgoingTransitions = pageLinks.filter(
|
||||
(link) =>
|
||||
link.from_pageId === currentPageId &&
|
||||
(link.transition?.video_url || link.transition?.reverse_video_url),
|
||||
);
|
||||
|
||||
outgoingTransitions.forEach((link) => {
|
||||
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 (
|
||||
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);
|
||||
if (reverseVideoUrl && isRelativeStoragePath(reverseVideoUrl)) {
|
||||
storagePaths.push(reverseVideoUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Batch fetch presigned URLs, then add to queue
|
||||
// Helper to resolve URL - prefer presigned if available, else fallback to proxy
|
||||
const resolveUrl = (
|
||||
storageKey: string,
|
||||
presignedUrls: Record<string, string>,
|
||||
): string => {
|
||||
// Use presigned URL if available (will be tested on actual download)
|
||||
if (presignedUrls[storageKey]) {
|
||||
return presignedUrls[storageKey];
|
||||
}
|
||||
// Fallback to resolveAssetPlaybackUrl (will use proxy)
|
||||
return resolveAssetPlaybackUrl(storageKey);
|
||||
};
|
||||
|
||||
// Two-phase preloading: current page first, then neighbors
|
||||
const addAssetsToQueue = async (
|
||||
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 = (
|
||||
id: string,
|
||||
storageKey: string,
|
||||
priority: number,
|
||||
assetType: 'image' | 'video' | 'audio' | 'transition' | 'other',
|
||||
pageId: string,
|
||||
): Promise<void> | null => {
|
||||
const resolvedUrl = resolveUrl(storageKey, presignedUrls);
|
||||
if (!resolvedUrl) return null;
|
||||
@ -569,19 +423,26 @@ export function usePreloadOrchestrator(
|
||||
? storageKey
|
||||
: extractStoragePath(resolvedUrl);
|
||||
|
||||
// Skip if already preloaded
|
||||
if (preloadedUrls.has(normalizedKey)) return null;
|
||||
// Check if already downloaded (blob exists) or download in progress
|
||||
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);
|
||||
|
||||
// Determine if partial preload applies (neighbor pages only, media files only)
|
||||
const isNeighborPage = pageId !== currentPageId;
|
||||
const maxBytes = getMaxBytesForAsset(assetType, isNeighborPage);
|
||||
// Create blob URL for images (instant navigation) and full downloads
|
||||
// Partial downloads (video/audio/transition) use presigned URL directly for playback
|
||||
const createBlobUrl = assetType === 'image' || maxBytes === undefined;
|
||||
const enableStreaming =
|
||||
PRELOAD_CONFIG.streaming.enabled &&
|
||||
(assetType === 'video' ||
|
||||
assetType === 'audio' ||
|
||||
assetType === 'transition');
|
||||
|
||||
// DownloadManager automatically handles presigned URL → proxy fallback
|
||||
return downloadManager
|
||||
.addJob({
|
||||
assetId: id,
|
||||
@ -592,9 +453,13 @@ export function usePreloadOrchestrator(
|
||||
assetType: mapAssetType(assetType),
|
||||
priority,
|
||||
storageKey: normalizedKey,
|
||||
createBlobUrl,
|
||||
persist: false,
|
||||
maxBytes,
|
||||
streamingMode: enableStreaming
|
||||
? {
|
||||
enabled: true,
|
||||
minBufferBytes: PRELOAD_CONFIG.streaming.minBufferBytes,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
.then(() => {
|
||||
if (isPresignedUrl(resolvedUrl)) {
|
||||
@ -602,6 +467,8 @@ export function usePreloadOrchestrator(
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
// Download failed - remove from Set so it can be retried
|
||||
preloadedUrls.delete(normalizedKey);
|
||||
logger.error('[PRELOAD] Download failed', {
|
||||
url: resolvedUrl.slice(-50),
|
||||
error: err?.message,
|
||||
@ -609,173 +476,164 @@ export function usePreloadOrchestrator(
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// PHASE 1: Load current page IMAGE backgrounds only and WAIT
|
||||
// Video/audio backgrounds stream on their own - don't block on them
|
||||
// ============================================
|
||||
logger.info('[PRELOAD] Phase 1: Loading current page backgrounds');
|
||||
// Phase 1: Current Page Assets (blocking for images only)
|
||||
setCurrentPhase('phase1_current_page');
|
||||
setPhaseProgress(0);
|
||||
|
||||
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) {
|
||||
phase1Total++;
|
||||
const job = createDownloadJob(
|
||||
`bg-img-${currentPageId}`,
|
||||
currentPage.background_image_url,
|
||||
PRELOAD_CONFIG.priority.currentPage + 200,
|
||||
'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)
|
||||
// These are started but not awaited - video player buffers on its own
|
||||
// Current page element images (blocking)
|
||||
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) {
|
||||
createDownloadJob(
|
||||
`bg-vid-${currentPageId}`,
|
||||
currentPage.background_video_url,
|
||||
PRELOAD_CONFIG.priority.currentPage + 150,
|
||||
'video',
|
||||
currentPageId,
|
||||
);
|
||||
// Not pushed to awaited jobs - video streams on its own
|
||||
}
|
||||
|
||||
if (currentPage?.background_audio_url) {
|
||||
createDownloadJob(
|
||||
`bg-aud-${currentPageId}`,
|
||||
currentPage.background_audio_url,
|
||||
PRELOAD_CONFIG.priority.currentPage + 100,
|
||||
'audio',
|
||||
currentPageId,
|
||||
);
|
||||
// Not pushed to awaited jobs - audio streams on its own
|
||||
}
|
||||
|
||||
// Wait ONLY for IMAGE backgrounds (they're small and essential)
|
||||
// Video/audio can stream - don't block the page
|
||||
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)');
|
||||
if (phase1BlockingJobs.length > 0) {
|
||||
await Promise.all(phase1BlockingJobs);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PHASE 2: Preload everything else (don't wait)
|
||||
// - Current page element assets (full downloads)
|
||||
// - 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');
|
||||
// Phase 2: Outgoing Transition Videos (preload for instant playback)
|
||||
setCurrentPhase('phase2_transitions');
|
||||
setPhaseProgress(0);
|
||||
|
||||
// Current page element assets (moved from Phase 1 for faster startup)
|
||||
const currentPageAssets = assets.filter(
|
||||
(asset) => asset.pageId === currentPageId,
|
||||
);
|
||||
currentPageAssets.forEach((asset) => {
|
||||
createDownloadJob(
|
||||
generateJobId(),
|
||||
asset.url,
|
||||
asset.priority,
|
||||
asset.assetType,
|
||||
asset.pageId,
|
||||
);
|
||||
});
|
||||
const phase2Jobs: Promise<void>[] = [];
|
||||
let phase2Total = 0;
|
||||
let phase2Completed = 0;
|
||||
|
||||
// Neighbor page element assets
|
||||
const neighborAssets = assets.filter(
|
||||
(asset) => asset.pageId !== currentPageId,
|
||||
);
|
||||
neighborAssets.forEach((asset) => {
|
||||
createDownloadJob(
|
||||
generateJobId(),
|
||||
asset.url,
|
||||
asset.priority,
|
||||
asset.assetType,
|
||||
asset.pageId,
|
||||
);
|
||||
});
|
||||
// Preload outgoing transition videos (forward + reverse)
|
||||
outgoingTransitions.forEach((link) => {
|
||||
const forwardVideoUrl = link.transition?.video_url;
|
||||
const reverseVideoUrl = link.transition?.reverse_video_url;
|
||||
|
||||
// Neighbor background assets
|
||||
const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
|
||||
neighbors.forEach(({ pageId }) => {
|
||||
const page = pages.find((p) => p.id === pageId);
|
||||
if (page?.background_image_url) {
|
||||
createDownloadJob(
|
||||
`bg-img-${pageId}`,
|
||||
page.background_image_url,
|
||||
PRELOAD_CONFIG.priority.neighborBase + 100,
|
||||
'image',
|
||||
pageId,
|
||||
// Preload forward transition video
|
||||
if (forwardVideoUrl) {
|
||||
phase2Total++;
|
||||
const job = createDownloadJob(
|
||||
`trans-fwd-${link.from_pageId}-${link.to_pageId}`,
|
||||
forwardVideoUrl,
|
||||
PRELOAD_CONFIG.priority.currentPage +
|
||||
PRELOAD_CONFIG.priority.assetType.transition,
|
||||
'transition',
|
||||
);
|
||||
if (job) {
|
||||
phase2Jobs.push(
|
||||
job.then(() => {
|
||||
phase2Completed++;
|
||||
setPhaseProgress(
|
||||
phase2Total > 0 ? (phase2Completed / phase2Total) * 100 : 100,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (page?.background_video_url) {
|
||||
createDownloadJob(
|
||||
`bg-vid-${pageId}`,
|
||||
page.background_video_url,
|
||||
PRELOAD_CONFIG.priority.neighborBase + 50,
|
||||
'video',
|
||||
pageId,
|
||||
);
|
||||
}
|
||||
if (page?.background_audio_url) {
|
||||
createDownloadJob(
|
||||
`bg-aud-${pageId}`,
|
||||
page.background_audio_url,
|
||||
PRELOAD_CONFIG.priority.neighborBase + 30,
|
||||
'audio',
|
||||
pageId,
|
||||
|
||||
// Preload reverse transition video (for potential back navigation from target)
|
||||
if (reverseVideoUrl) {
|
||||
phase2Total++;
|
||||
const job = createDownloadJob(
|
||||
`trans-rev-${link.from_pageId}-${link.to_pageId}`,
|
||||
reverseVideoUrl,
|
||||
PRELOAD_CONFIG.priority.currentPage +
|
||||
PRELOAD_CONFIG.priority.assetType.transition - 10,
|
||||
'transition',
|
||||
);
|
||||
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) {
|
||||
logger.info('[PRELOAD] Fetching presigned URLs', {
|
||||
count: storagePaths.length,
|
||||
});
|
||||
queuePresignedUrls(storagePaths)
|
||||
.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();
|
||||
})
|
||||
.catch(async (error) => {
|
||||
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)
|
||||
.catch(async () => {
|
||||
await addAssetsToQueue();
|
||||
});
|
||||
} else {
|
||||
// No storage paths to presign, add directly to queue
|
||||
addAssetsToQueue();
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
currentPageId,
|
||||
networkInfo.isOnline,
|
||||
neighborGraph,
|
||||
pages,
|
||||
pageLinks,
|
||||
addToQueue,
|
||||
maxNeighborDepth,
|
||||
]);
|
||||
}, [enabled, currentPageId, networkInfo.isOnline, elements, pages, pageLinks]);
|
||||
|
||||
const isCurrentPageReady =
|
||||
currentPhase === 'phase2_transitions' || currentPhase === 'complete';
|
||||
|
||||
const areTransitionsReady = currentPhase === 'complete';
|
||||
|
||||
return {
|
||||
isPreloading,
|
||||
@ -787,6 +645,10 @@ export function usePreloadOrchestrator(
|
||||
getCachedBlobUrl,
|
||||
isUrlPreloaded,
|
||||
getReadyBlobUrl,
|
||||
areNeighborBackgroundsReady,
|
||||
getReadyBlob,
|
||||
currentPhase,
|
||||
phaseProgress,
|
||||
isCurrentPageReady,
|
||||
areTransitionsReady,
|
||||
};
|
||||
}
|
||||
|
||||
159
frontend/src/hooks/useSlideTransition.ts
Normal file
159
frontend/src/hooks/useSlideTransition.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -2,51 +2,45 @@
|
||||
* useTransitionPlayback Hook
|
||||
*
|
||||
* 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 {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import axios from 'axios';
|
||||
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 { TRANSITION_CONFIG } from '../config/transition.config';
|
||||
import {
|
||||
useVideoBlobUrl,
|
||||
useVideoTimeouts,
|
||||
type PreloadCacheProvider,
|
||||
} from './video';
|
||||
|
||||
export type ReverseMode = 'none' | 'separate';
|
||||
|
||||
export interface TransitionConfig {
|
||||
videoUrl: string;
|
||||
storageKey?: string; // Raw storage path for cache lookup
|
||||
storageKey?: string;
|
||||
reverseMode: ReverseMode;
|
||||
reverseVideoUrl?: string;
|
||||
/** Raw storage path for reverse video cache lookup */
|
||||
reverseStorageKey?: string;
|
||||
durationSec?: number;
|
||||
targetPageId?: string;
|
||||
displayName?: string;
|
||||
/** Whether this is a back navigation (for history management) */
|
||||
isBack?: boolean;
|
||||
}
|
||||
|
||||
export interface UseTransitionPlaybackOptions {
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
transition: TransitionConfig | null;
|
||||
/** Called when playback completes. isBack indicates if this was a back navigation. */
|
||||
onComplete: (targetPageId?: string, isBack?: boolean) => void;
|
||||
onError?: (reason: string) => void;
|
||||
|
||||
timeouts?: {
|
||||
playbackStartMs?: number;
|
||||
durationBufferMs?: number;
|
||||
@ -61,6 +55,7 @@ export interface UseTransitionPlaybackOptions {
|
||||
preloadedUrls?: Set<string>;
|
||||
getCachedBlobUrl?: (url: string) => Promise<string | null>;
|
||||
getReadyBlobUrl?: (url: string) => string | null;
|
||||
getReadyBlob?: (url: string) => Blob | null;
|
||||
};
|
||||
}
|
||||
|
||||
@ -74,50 +69,83 @@ export type PlaybackPhase =
|
||||
export interface UseTransitionPlaybackResult {
|
||||
phase: PlaybackPhase;
|
||||
isBuffering: boolean;
|
||||
/** True once first video frame has been displayed */
|
||||
isVideoReady: boolean;
|
||||
isReversing: boolean;
|
||||
cancel: () => 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 = {
|
||||
playbackStartMs: 3000,
|
||||
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 => {
|
||||
if (isSafari()) {
|
||||
return 350;
|
||||
}
|
||||
if (isFirefox()) {
|
||||
return 300;
|
||||
}
|
||||
return 300;
|
||||
const { finishBeforeEndMs } = TRANSITION_CONFIG;
|
||||
if (isSafari()) return finishBeforeEndMs.safari;
|
||||
if (isFirefox()) return finishBeforeEndMs.firefox;
|
||||
return finishBeforeEndMs.default;
|
||||
};
|
||||
|
||||
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> {
|
||||
if (urls.length === 0) return;
|
||||
|
||||
@ -159,99 +187,79 @@ export function useTransitionPlayback(
|
||||
|
||||
const playbackStartMs =
|
||||
customTimeouts?.playbackStartMs ?? DEFAULT_TIMEOUTS.playbackStartMs;
|
||||
const hardTimeoutMs =
|
||||
customTimeouts?.hardTimeoutMs ?? DEFAULT_TIMEOUTS.hardTimeoutMs;
|
||||
|
||||
const [phase, setPhase] = useState<PlaybackPhase>('idle');
|
||||
const [isVideoReady, setIsVideoReady] = useState(false);
|
||||
const [state, dispatch] = useReducer(transitionReducer, initialState);
|
||||
|
||||
const didFinishRef = 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 currentPlayableUrlRef = useRef<string | null>(null);
|
||||
const startWatchdogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const finishTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hardTimeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const didSetInitialTimeRef = useRef(false);
|
||||
const isWaitingForDataRef = useRef(false);
|
||||
const lastProgressTimeRef = useRef<number>(0);
|
||||
const activeSourceKeyRef = useRef<string | null>(null);
|
||||
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
const onErrorRef = useRef(onError);
|
||||
const transitionRef = useRef(transition);
|
||||
const featuresRef = useRef(features);
|
||||
const preloadRef = useRef(preload);
|
||||
|
||||
// Determine which video URL to use:
|
||||
// For back navigation with a reversed video, use reverseVideoUrl
|
||||
// Otherwise, use the original videoUrl
|
||||
useEffect(() => {
|
||||
onCompleteRef.current = onComplete;
|
||||
onErrorRef.current = onError;
|
||||
transitionRef.current = transition;
|
||||
featuresRef.current = features;
|
||||
});
|
||||
|
||||
const sourceUrl = useMemo(() => {
|
||||
if (!transition) return '';
|
||||
if (transition.isBack) {
|
||||
return transition.reverseVideoUrl || '';
|
||||
}
|
||||
if (transition.isBack) return transition.reverseVideoUrl || '';
|
||||
return transition.videoUrl;
|
||||
}, [transition]);
|
||||
|
||||
// Storage key for cache lookup - use reversed video key for back navigation
|
||||
const storageKey = useMemo(() => {
|
||||
if (!transition) return undefined;
|
||||
if (transition.isBack) {
|
||||
return transition.reverseVideoUrl || undefined;
|
||||
}
|
||||
// For back navigation, use reverseStorageKey (raw path) for cache lookup
|
||||
if (transition.isBack) return transition.reverseStorageKey || undefined;
|
||||
return transition.storageKey;
|
||||
}, [transition]);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
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 { setTimer, clearTimer, clearAllTimers } = useVideoTimeouts();
|
||||
|
||||
const revokeBlobUrl = useCallback((force = false) => {
|
||||
if (!force || !lastLoadedBlobUrlRef.current) return;
|
||||
URL.revokeObjectURL(lastLoadedBlobUrlRef.current);
|
||||
lastLoadedBlobUrlRef.current = null;
|
||||
}, []);
|
||||
const preloadCache: PreloadCacheProvider = useMemo(
|
||||
() => ({
|
||||
getReadyBlob: preload?.getReadyBlob,
|
||||
getCachedBlobUrl: preload?.getCachedBlobUrl,
|
||||
}),
|
||||
[preload?.getReadyBlob, preload?.getCachedBlobUrl],
|
||||
);
|
||||
|
||||
const {
|
||||
resolvedUrl,
|
||||
isResolving,
|
||||
revoke: revokeBlobUrl,
|
||||
} = useVideoBlobUrl({
|
||||
sourceUrl,
|
||||
storageKey,
|
||||
preloadCache,
|
||||
});
|
||||
|
||||
const finishPlayback = useCallback(
|
||||
async (reason: string) => {
|
||||
if (didFinishRef.current) return;
|
||||
didFinishRef.current = true;
|
||||
activeSourceUrlRef.current = null;
|
||||
clearTimers();
|
||||
clearAllTimers();
|
||||
|
||||
const video = videoRef.current;
|
||||
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();
|
||||
}
|
||||
|
||||
const currentTransition = transitionRef.current;
|
||||
const currentFeatures = featuresRef.current;
|
||||
|
||||
logger.info('Transition playback finished', {
|
||||
reason,
|
||||
displayName: currentTransition?.displayName,
|
||||
targetPageId: currentTransition?.targetPageId,
|
||||
});
|
||||
logger.info('[TRANSITION] Finished', { reason });
|
||||
|
||||
setPhase('finishing');
|
||||
dispatch({ type: 'SET_FINISHING' });
|
||||
|
||||
if (
|
||||
currentFeatures?.preDecodeImages &&
|
||||
@ -262,50 +270,42 @@ export function useTransitionPlayback(
|
||||
const imageUrls = currentFeatures.getTargetPageImages();
|
||||
await waitForImages(imageUrls);
|
||||
} catch {
|
||||
// Ignore pre-decode errors
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
setPhase('completed');
|
||||
dispatch({ type: 'SET_COMPLETED' });
|
||||
onCompleteRef.current(
|
||||
currentTransition?.targetPageId,
|
||||
currentTransition?.isBack,
|
||||
);
|
||||
},
|
||||
[clearTimers, videoRef],
|
||||
[clearAllTimers, videoRef],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(reason: string) => {
|
||||
if (didFinishRef.current) return;
|
||||
logger.error('Transition playback error', { reason });
|
||||
logger.error('[TRANSITION] Error', { reason });
|
||||
onErrorRef.current?.(reason);
|
||||
finishPlayback(reason);
|
||||
},
|
||||
[finishPlayback],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onCompleteRef.current = onComplete;
|
||||
onErrorRef.current = onError;
|
||||
transitionRef.current = transition;
|
||||
featuresRef.current = features;
|
||||
preloadRef.current = preload;
|
||||
});
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (phase === 'idle') return;
|
||||
clearTimers();
|
||||
if (state.phase === 'idle') return;
|
||||
clearAllTimers();
|
||||
didFinishRef.current = true;
|
||||
setPhase('idle');
|
||||
dispatch({ type: 'RESET' });
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
}
|
||||
revokeBlobUrl(true);
|
||||
}, [phase, clearTimers, videoRef, revokeBlobUrl]);
|
||||
revokeBlobUrl();
|
||||
}, [state.phase, clearAllTimers, videoRef, revokeBlobUrl]);
|
||||
|
||||
const forceComplete = useCallback(() => {
|
||||
finishPlayback('forced');
|
||||
@ -314,274 +314,68 @@ export function useTransitionPlayback(
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
const currentTransition = transitionRef.current;
|
||||
if (!currentTransition || !video) {
|
||||
|
||||
if (!currentTransition || !video || !resolvedUrl || isResolving) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sourceUrl) {
|
||||
logger.info('No playable transition source, skipping playback', {
|
||||
isBack: currentTransition.isBack,
|
||||
targetPageId: currentTransition.targetPageId,
|
||||
});
|
||||
void finishPlayback('missing-source');
|
||||
return;
|
||||
}
|
||||
|
||||
// Include isBack in the key so same video can play forward or as reversed
|
||||
const sourceKey = `${sourceUrl}|${currentTransition.isBack ? 'back' : 'forward'}`;
|
||||
if (activeSourceUrlRef.current === sourceKey) {
|
||||
logger.info('Skipping duplicate effect for same source', {
|
||||
sourceUrl,
|
||||
isBack: currentTransition.isBack,
|
||||
});
|
||||
if (activeSourceKeyRef.current === sourceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeSourceUrlRef.current = sourceKey;
|
||||
activeSourceKeyRef.current = sourceKey;
|
||||
dispatch({ type: 'START_PREPARING', sourceKey });
|
||||
didFinishRef.current = false;
|
||||
didStartPlaybackRef.current = false;
|
||||
didTryDecodeRetryRef.current = false;
|
||||
currentPlayableUrlRef.current = null;
|
||||
setPhase('preparing');
|
||||
didSetInitialTimeRef.current = false;
|
||||
isWaitingForDataRef.current = false;
|
||||
lastProgressTimeRef.current = Date.now();
|
||||
|
||||
const configuredDurationSec = Number(currentTransition.durationSec);
|
||||
|
||||
const getMediaErrorDetails = () => {
|
||||
if (!video.error) return null;
|
||||
const mediaError = video.error as MediaError & { message?: string };
|
||||
return {
|
||||
code: mediaError.code,
|
||||
message: mediaError.message || '',
|
||||
};
|
||||
return { code: mediaError.code, message: mediaError.message || '' };
|
||||
};
|
||||
|
||||
const logIssue = (reason: string, error?: unknown) => {
|
||||
logger.error('Transition playback issue:', {
|
||||
const logIssue = (reason: string) => {
|
||||
logger.error('[TRANSITION] Issue', {
|
||||
reason,
|
||||
src: video.currentSrc || sourceUrl,
|
||||
readyState: video.readyState,
|
||||
networkState: video.networkState,
|
||||
duration: video.duration,
|
||||
configuredDurationSec,
|
||||
isBack: currentTransition.isBack,
|
||||
mediaError: getMediaErrorDetails(),
|
||||
error: error instanceof Error ? error : { error },
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleFinishByDuration = (durationSec: number) => {
|
||||
if (
|
||||
!Number.isFinite(durationSec) ||
|
||||
durationSec <= 0 ||
|
||||
finishTimerRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Use browser-specific offset to prevent black flash at video end
|
||||
if (!Number.isFinite(durationSec) || durationSec <= 0) return;
|
||||
const finishBeforeEndMs = getFinishBeforeEndMs();
|
||||
const finishMs = Math.max(100, durationSec * 1000 - finishBeforeEndMs);
|
||||
finishTimerRef.current = setTimeout(
|
||||
() => finishPlayback('duration-timer'),
|
||||
finishMs,
|
||||
);
|
||||
setTimer('finishByDuration', () => finishPlayback('duration-timer'), finishMs);
|
||||
};
|
||||
|
||||
const attemptPlay = () => {
|
||||
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 = () => {
|
||||
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();
|
||||
};
|
||||
|
||||
@ -591,63 +385,51 @@ export function useTransitionPlayback(
|
||||
};
|
||||
|
||||
const onPlaying = () => {
|
||||
logger.info('onPlaying fired', {
|
||||
isBack: currentTransition.isBack,
|
||||
didStartPlayback: didStartPlaybackRef.current,
|
||||
didFinish: didFinishRef.current,
|
||||
});
|
||||
|
||||
if (didFinishRef.current) return;
|
||||
|
||||
didStartPlaybackRef.current = true;
|
||||
setPhase('playing');
|
||||
|
||||
if (startWatchdogTimerRef.current) {
|
||||
clearTimeout(startWatchdogTimerRef.current);
|
||||
startWatchdogTimerRef.current = null;
|
||||
if (isWaitingForDataRef.current) {
|
||||
dispatch({ type: 'SET_WAITING', isWaiting: false });
|
||||
isWaitingForDataRef.current = false;
|
||||
}
|
||||
|
||||
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+)
|
||||
// This fires when a frame is actually sent to the compositor - no guesswork
|
||||
didStartPlaybackRef.current = true;
|
||||
dispatch({ type: 'SET_PLAYING' });
|
||||
clearTimer('playbackWatchdog');
|
||||
|
||||
if ('requestVideoFrameCallback' in video) {
|
||||
const rvfc = video.requestVideoFrameCallback.bind(video);
|
||||
|
||||
// First callback: frame is composited, safe to show overlay
|
||||
rvfc((_now, _metadata) => {
|
||||
rvfc(() => {
|
||||
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 = (
|
||||
_now2: number,
|
||||
_now: number,
|
||||
metadata: VideoFrameCallbackMetadata,
|
||||
) => {
|
||||
if (didFinishRef.current) return;
|
||||
|
||||
const duration = video.duration;
|
||||
// Finish 300ms before end - gives margin for black/fade frames
|
||||
// that some videos have in the last 100-200ms
|
||||
const { rvfcThreshold } = TRANSITION_CONFIG;
|
||||
|
||||
if (
|
||||
Number.isFinite(duration) &&
|
||||
metadata.mediaTime >= duration - 0.3
|
||||
metadata.mediaTime >= duration - rvfcThreshold
|
||||
) {
|
||||
finishPlayback('rvfc-end');
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue monitoring each frame
|
||||
rvfc(monitorEnd);
|
||||
};
|
||||
|
||||
rvfc(monitorEnd);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers without requestVideoFrameCallback
|
||||
scheduleAfterPaint(() => {
|
||||
if (!didFinishRef.current) {
|
||||
setIsVideoReady(true);
|
||||
dispatch({ type: 'SET_VIDEO_READY' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -659,6 +441,7 @@ export function useTransitionPlayback(
|
||||
: Number.isFinite(mediaDurationSec) && mediaDurationSec > 0
|
||||
? mediaDurationSec
|
||||
: NaN;
|
||||
|
||||
if (Number.isFinite(durationSec) && durationSec > 0) {
|
||||
scheduleFinishByDuration(durationSec);
|
||||
}
|
||||
@ -668,30 +451,28 @@ export function useTransitionPlayback(
|
||||
finishPlayback('ended');
|
||||
};
|
||||
|
||||
// Backup handler for browsers without requestVideoFrameCallback
|
||||
// Note: timeupdate fires infrequently (~250ms in Safari), so this is just a fallback
|
||||
const onTimeUpdate = () => {
|
||||
if (didFinishRef.current) return;
|
||||
|
||||
const duration = video.duration;
|
||||
if (!Number.isFinite(duration)) return;
|
||||
|
||||
// Large buffer since timeupdate is infrequent
|
||||
// Safari: 600ms, Others: 400ms
|
||||
const safetyBuffer = isSafari() ? 0.6 : 0.4;
|
||||
const { timeUpdateSafetyBuffer } = TRANSITION_CONFIG;
|
||||
const safetyBuffer = isSafari()
|
||||
? timeUpdateSafetyBuffer.safari
|
||||
: timeUpdateSafetyBuffer.default;
|
||||
|
||||
if (video.currentTime >= duration - safetyBuffer) {
|
||||
finishPlayback('timeupdate-end');
|
||||
}
|
||||
};
|
||||
|
||||
const onVideoError = async () => {
|
||||
const onVideoError = () => {
|
||||
if (didFinishRef.current) return;
|
||||
logIssue('video-error');
|
||||
|
||||
const errorCode = video.error?.code;
|
||||
if (errorCode === 3 && !didTryDecodeRetryRef.current) {
|
||||
logger.info('Safari video decode error, attempting reload');
|
||||
didTryDecodeRetryRef.current = true;
|
||||
video.load();
|
||||
attemptPlay();
|
||||
@ -712,6 +493,42 @@ export function useTransitionPlayback(
|
||||
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('canplay', onCanPlay);
|
||||
video.addEventListener('playing', onPlaying);
|
||||
@ -720,14 +537,33 @@ export function useTransitionPlayback(
|
||||
video.addEventListener('error', onVideoError);
|
||||
video.addEventListener('abort', onAbort);
|
||||
video.addEventListener('stalled', onStalled);
|
||||
video.addEventListener('waiting', onWaiting);
|
||||
video.addEventListener('progress', onProgress);
|
||||
|
||||
hardTimeoutTimerRef.current = setTimeout(() => {
|
||||
if (didFinishRef.current) return;
|
||||
logIssue('hard-timeout');
|
||||
handleError('hard-timeout');
|
||||
}, hardTimeoutMs);
|
||||
logger.info('[TRANSITION] Starting', {
|
||||
url: resolvedUrl.slice(-60),
|
||||
isBack: currentTransition.isBack,
|
||||
});
|
||||
|
||||
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 () => {
|
||||
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
@ -738,37 +574,40 @@ export function useTransitionPlayback(
|
||||
video.removeEventListener('error', onVideoError);
|
||||
video.removeEventListener('abort', onAbort);
|
||||
video.removeEventListener('stalled', onStalled);
|
||||
clearTimers();
|
||||
video.removeEventListener('waiting', onWaiting);
|
||||
video.removeEventListener('progress', onProgress);
|
||||
clearAllTimers();
|
||||
};
|
||||
}, [
|
||||
sourceUrl,
|
||||
storageKey,
|
||||
transition?.isBack,
|
||||
resolvedUrl,
|
||||
isResolving,
|
||||
videoRef,
|
||||
playbackStartMs,
|
||||
hardTimeoutMs,
|
||||
clearTimers,
|
||||
revokeBlobUrl,
|
||||
setTimer,
|
||||
clearTimer,
|
||||
clearAllTimers,
|
||||
finishPlayback,
|
||||
handleError,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!transition) {
|
||||
setPhase('idle');
|
||||
setIsVideoReady(false);
|
||||
activeSourceUrlRef.current = null;
|
||||
dispatch({ type: 'RESET' });
|
||||
didFinishRef.current = false;
|
||||
didStartPlaybackRef.current = false;
|
||||
activeSourceKeyRef.current = null;
|
||||
}
|
||||
}, [transition]);
|
||||
|
||||
return {
|
||||
phase,
|
||||
// Show buffering until video first frame is painted (prevents START black flash)
|
||||
phase: state.phase,
|
||||
isBuffering:
|
||||
phase === 'preparing' || (phase === 'playing' && !isVideoReady),
|
||||
isReversing: false, // No longer support frame-stepping reverse
|
||||
state.phase === 'preparing' ||
|
||||
(state.phase === 'playing' && !state.isVideoReady) ||
|
||||
(state.phase === 'playing' && state.isWaitingForData),
|
||||
isVideoReady: state.isVideoReady,
|
||||
isReversing: false,
|
||||
cancel,
|
||||
forceComplete,
|
||||
};
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { TransitionPreviewState } from '../types/presentation';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export type { TransitionPreviewState };
|
||||
|
||||
@ -128,6 +129,14 @@ export function useTransitionPreview({
|
||||
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);
|
||||
},
|
||||
[isNavigationElementType, onError],
|
||||
|
||||
175
frontend/src/hooks/useTransitionSettings.ts
Normal file
175
frontend/src/hooks/useTransitionSettings.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
59
frontend/src/hooks/video/index.ts
Normal file
59
frontend/src/hooks/video/index.ts
Normal 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';
|
||||
185
frontend/src/hooks/video/useVideoBlobUrl.ts
Normal file
185
frontend/src/hooks/video/useVideoBlobUrl.ts
Normal 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;
|
||||
216
frontend/src/hooks/video/useVideoBufferingState.ts
Normal file
216
frontend/src/hooks/video/useVideoBufferingState.ts
Normal 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;
|
||||
174
frontend/src/hooks/video/useVideoErrorRecovery.ts
Normal file
174
frontend/src/hooks/video/useVideoErrorRecovery.ts
Normal 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;
|
||||
114
frontend/src/hooks/video/useVideoEventManager.ts
Normal file
114
frontend/src/hooks/video/useVideoEventManager.ts
Normal 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;
|
||||
123
frontend/src/hooks/video/useVideoFirstFrame.ts
Normal file
123
frontend/src/hooks/video/useVideoFirstFrame.ts
Normal 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;
|
||||
337
frontend/src/hooks/video/useVideoPlaybackCore.ts
Normal file
337
frontend/src/hooks/video/useVideoPlaybackCore.ts
Normal 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;
|
||||
245
frontend/src/hooks/video/useVideoPlayer.ts
Normal file
245
frontend/src/hooks/video/useVideoPlayer.ts
Normal 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;
|
||||
93
frontend/src/hooks/video/useVideoTimeouts.ts
Normal file
93
frontend/src/hooks/video/useVideoTimeouts.ts
Normal 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;
|
||||
@ -229,22 +229,8 @@ export class AssetCacheService {
|
||||
|
||||
if (!downloadUrl) continue;
|
||||
|
||||
// Determine download parameters based on mode
|
||||
const isOnlineMode = mode === 'online';
|
||||
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;
|
||||
// Always full downloads with blob URLs for reliable playback
|
||||
// (presigned URL streaming fails on mobile Safari/Chrome)
|
||||
|
||||
try {
|
||||
await downloadManager
|
||||
@ -257,9 +243,7 @@ export class AssetCacheService {
|
||||
assetType: asset.assetType,
|
||||
priority: asset.priority,
|
||||
storageKey: asset.storageKey,
|
||||
createBlobUrl,
|
||||
persist: mode === 'offline', // Only persist for offline mode
|
||||
maxBytes,
|
||||
})
|
||||
.then(() => {
|
||||
if (isPresignedUrl(downloadUrl)) {
|
||||
@ -287,9 +271,7 @@ export class AssetCacheService {
|
||||
assetType: asset.assetType,
|
||||
priority: asset.priority,
|
||||
storageKey: asset.storageKey,
|
||||
createBlobUrl,
|
||||
persist: mode === 'offline',
|
||||
maxBytes,
|
||||
});
|
||||
} catch {
|
||||
// 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
|
||||
*/
|
||||
|
||||
@ -167,12 +167,19 @@ export const scheduleAfterPaint = (callback: () => void): void => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Get crossfade duration from CSS custom property.
|
||||
* Single source of truth: CSS variable --crossfade-duration in main.css.
|
||||
* Get crossfade duration from CSS custom property or override.
|
||||
* 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
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
@ -210,8 +210,8 @@ export const createDefaultElement = (
|
||||
id: createLocalId(),
|
||||
type,
|
||||
label: ELEMENT_TYPE_LABELS[type] || type,
|
||||
xPercent: clamp(12 + index * 4, 5, 80),
|
||||
yPercent: clamp(16 + index * 6, 8, 85),
|
||||
xPercent: clamp(45 + index * 3, 10, 85), // Center horizontally
|
||||
yPercent: clamp(45 + index * 4, 15, 80), // Center vertically
|
||||
appearDelaySec: 0,
|
||||
appearDurationSec: null,
|
||||
};
|
||||
|
||||
@ -161,16 +161,26 @@ export function extractPageLinksAndElements(
|
||||
}
|
||||
|
||||
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) {
|
||||
const hasTransitionVideo =
|
||||
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string';
|
||||
const hasReverseVideo =
|
||||
el.reverseVideoUrl && typeof el.reverseVideoUrl === 'string';
|
||||
|
||||
pageLinks.push({
|
||||
id: `synthetic-${page.id}-${el.id || preloadElements.length}`,
|
||||
from_pageId: page.id,
|
||||
to_pageId: resolvedTargetPageId,
|
||||
is_active: true,
|
||||
transition:
|
||||
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string'
|
||||
hasTransitionVideo || hasReverseVideo
|
||||
? {
|
||||
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,
|
||||
});
|
||||
@ -237,16 +247,26 @@ export function extractPageLinksOnly(
|
||||
}
|
||||
|
||||
if (resolvedTargetPageId && resolvedTargetPageId !== page.id) {
|
||||
const hasTransitionVideo =
|
||||
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string';
|
||||
const hasReverseVideo =
|
||||
el.reverseVideoUrl && typeof el.reverseVideoUrl === 'string';
|
||||
|
||||
pageLinks.push({
|
||||
id: `synthetic-${page.id}-${el.id || Math.random().toString(36).slice(2)}`,
|
||||
from_pageId: page.id,
|
||||
to_pageId: resolvedTargetPageId,
|
||||
is_active: true,
|
||||
transition:
|
||||
el.transitionVideoUrl && typeof el.transitionVideoUrl === 'string'
|
||||
hasTransitionVideo || hasReverseVideo
|
||||
? {
|
||||
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,
|
||||
});
|
||||
|
||||
@ -20,6 +20,11 @@ export interface FontOption {
|
||||
* Supported fonts for UI elements
|
||||
*/
|
||||
export const FONT_OPTIONS: FontOption[] = [
|
||||
{
|
||||
key: 'maple-medium',
|
||||
label: 'Maple Medium',
|
||||
fontFamily: 'Maple',
|
||||
},
|
||||
{
|
||||
key: 'instrument-sans',
|
||||
label: 'Instrument Sans',
|
||||
|
||||
@ -15,6 +15,7 @@ import type {
|
||||
ProjectDownloadProgressEvent,
|
||||
ProjectDownloadCompleteEvent,
|
||||
BlobUrlReadyEvent,
|
||||
StreamingReadyEvent,
|
||||
} from '../../types/offline';
|
||||
|
||||
type EventMap = {
|
||||
@ -26,6 +27,7 @@ type EventMap = {
|
||||
[OFFLINE_CONFIG.events.projectDownloadComplete]: ProjectDownloadCompleteEvent;
|
||||
[OFFLINE_CONFIG.events.queueUpdate]: void;
|
||||
[OFFLINE_CONFIG.events.blobUrlReady]: BlobUrlReadyEvent;
|
||||
[OFFLINE_CONFIG.events.streamingReady]: StreamingReadyEvent;
|
||||
};
|
||||
|
||||
type EventCallback<T> = (data: T) => void;
|
||||
@ -182,6 +184,13 @@ class DownloadEventBusClass {
|
||||
emitBlobUrlReady(data: BlobUrlReadyEvent): void {
|
||||
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
|
||||
|
||||
@ -40,14 +40,19 @@ interface DownloadJob {
|
||||
retryCount: number;
|
||||
addedAt: number;
|
||||
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)
|
||||
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
|
||||
abortController?: AbortController;
|
||||
resolve?: () => void;
|
||||
reject?: (error: Error) => void;
|
||||
|
||||
/** Streaming mode state */
|
||||
streamingMode?: {
|
||||
enabled: boolean;
|
||||
minBufferBytes: number;
|
||||
streamingUrl: string;
|
||||
didSignalReady: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
class DownloadManagerClass {
|
||||
@ -59,9 +64,9 @@ class DownloadManagerClass {
|
||||
// Blob URL cache for instant lookup (storageKey → blobUrl)
|
||||
private readyBlobUrls: Map<string, string> = new Map();
|
||||
|
||||
// Track partial downloads completed in this session (not persisted)
|
||||
// Prevents re-downloading same partial content on repeated page visits
|
||||
private partialDownloadsReady: Set<string> = new Set();
|
||||
// Raw Blob cache for creating fresh blob URLs (storageKey → Blob)
|
||||
// Used by transitions to avoid decoder state issues with pre-created blob URLs
|
||||
private readyBlobs: Map<string, Blob> = new Map();
|
||||
|
||||
private config = {
|
||||
maxConcurrent: PRELOAD_CONFIG.maxConcurrentDownloads,
|
||||
@ -73,6 +78,7 @@ class DownloadManagerClass {
|
||||
|
||||
/**
|
||||
* Add a download job to the queue
|
||||
* Always creates blob URLs for reliable playback on all devices
|
||||
*/
|
||||
async addJob(params: {
|
||||
assetId: string;
|
||||
@ -83,50 +89,26 @@ class DownloadManagerClass {
|
||||
assetType: AssetType;
|
||||
priority?: number;
|
||||
storageKey?: string; // Optional, will extract if not provided
|
||||
createBlobUrl?: boolean; // Create blob URL after download
|
||||
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> {
|
||||
const storageKey = params.storageKey || extractStoragePath(params.url);
|
||||
const isPartialDownload = params.maxBytes !== undefined;
|
||||
|
||||
// For partial downloads, check session cache first (fast path)
|
||||
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
|
||||
// Check cache status - if fully cached, create blob URL and return
|
||||
const assetInfo = await StorageManager.getAssetInfo(storageKey);
|
||||
|
||||
if (assetInfo?.exists) {
|
||||
if (isPartialDownload) {
|
||||
// For partial downloads, any cached version is sufficient
|
||||
this.partialDownloadsReady.add(storageKey);
|
||||
return;
|
||||
if (assetInfo?.exists && !assetInfo.isPartial) {
|
||||
// Fully cached - create blob URL if not already ready
|
||||
if (!this.readyBlobUrls.has(storageKey)) {
|
||||
await this.createBlobUrlFromCache(storageKey);
|
||||
}
|
||||
|
||||
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
|
||||
return;
|
||||
}
|
||||
|
||||
// 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)
|
||||
if (
|
||||
this.queue.some((j) => j.storageKey === storageKey) ||
|
||||
@ -134,27 +116,10 @@ class DownloadManagerClass {
|
||||
(j) => j.storageKey === storageKey,
|
||||
)
|
||||
) {
|
||||
return; // Already queued - no log needed
|
||||
}
|
||||
|
||||
// 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; // Already queued
|
||||
}
|
||||
|
||||
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 = {
|
||||
id: `dl-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
assetId: params.assetId,
|
||||
@ -173,12 +138,19 @@ class DownloadManagerClass {
|
||||
retryCount: 0,
|
||||
addedAt: Date.now(),
|
||||
storageKey,
|
||||
createBlobUrl: shouldCreateBlobUrl,
|
||||
persist: shouldPersist,
|
||||
maxBytes: params.maxBytes,
|
||||
isPartial: isPartialDownload,
|
||||
persist: params.persist ?? true,
|
||||
resolve,
|
||||
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)
|
||||
@ -274,39 +246,23 @@ class DownloadManagerClass {
|
||||
});
|
||||
|
||||
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, {
|
||||
signal: job.abortController.signal,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Accept both 200 OK and 206 Partial Content
|
||||
if (!response.ok && response.status !== 206) {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('content-length');
|
||||
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;
|
||||
|
||||
if (response.body) {
|
||||
// Stream with progress tracking
|
||||
const reader = response.body.getReader();
|
||||
const chunks: BlobPart[] = [];
|
||||
let reachedLimit = false;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@ -315,22 +271,6 @@ class DownloadManagerClass {
|
||||
chunks.push(value);
|
||||
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.totalBytes > 0
|
||||
? Math.round((job.bytesLoaded / job.totalBytes) * 100)
|
||||
@ -343,6 +283,30 @@ class DownloadManagerClass {
|
||||
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
|
||||
if (job.persist !== false) {
|
||||
await OfflineDbManager.updateQueueProgress(
|
||||
@ -357,11 +321,6 @@ class DownloadManagerClass {
|
||||
type:
|
||||
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 {
|
||||
// No streaming, get blob directly
|
||||
blob = await response.blob();
|
||||
@ -371,34 +330,25 @@ class DownloadManagerClass {
|
||||
}
|
||||
|
||||
// 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, {
|
||||
id: job.assetId,
|
||||
projectId: job.projectId,
|
||||
filename: job.filename,
|
||||
variantType: job.variantType,
|
||||
assetType: job.assetType,
|
||||
isPartial: job.isPartial || false,
|
||||
isPartial: false,
|
||||
});
|
||||
|
||||
if (job.isPartial) {
|
||||
// Mark partial download as ready in session cache
|
||||
this.partialDownloadsReady.add(job.storageKey);
|
||||
// Store ORIGINAL blob directly (bypass cache retrieval to avoid potential corruption)
|
||||
this.readyBlobs.set(job.storageKey, blob);
|
||||
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
|
||||
// When the browser fetches the full media, SW will cache it using the storage key
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Always create blob URL for reliable playback on all devices
|
||||
await this.createBlobUrlFromCache(job.storageKey);
|
||||
|
||||
// Mark as completed
|
||||
job.status = 'completed';
|
||||
@ -625,7 +575,6 @@ class DownloadManagerClass {
|
||||
retryCount: item.retryCount,
|
||||
addedAt: item.addedAt,
|
||||
storageKey,
|
||||
createBlobUrl: true, // Create blob URL for resumed downloads
|
||||
persist: true,
|
||||
};
|
||||
|
||||
@ -649,6 +598,16 @@ class DownloadManagerClass {
|
||||
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.
|
||||
* Use this when fetching via XHR (e.g., transition playback) to enable caching.
|
||||
@ -670,6 +629,9 @@ class DownloadManagerClass {
|
||||
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
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
this.readyBlobUrls.set(storageKey, blobUrl);
|
||||
@ -701,6 +663,13 @@ class DownloadManagerClass {
|
||||
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);
|
||||
|
||||
// Decode images to prevent white flash
|
||||
@ -727,41 +696,13 @@ class DownloadManagerClass {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a presigned URL → storage key mapping with the Service Worker.
|
||||
* 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)
|
||||
* Clear blob URLs cache (call on unmount to prevent memory leaks)
|
||||
*/
|
||||
clearBlobUrls(): void {
|
||||
this.readyBlobUrls.forEach((blobUrl) => URL.revokeObjectURL(blobUrl));
|
||||
this.readyBlobUrls.clear();
|
||||
this.partialDownloadsReady.clear();
|
||||
|
||||
// Clear SW URL mappings (optional, SW has its own cleanup interval)
|
||||
if (navigator.serviceWorker?.controller) {
|
||||
navigator.serviceWorker.controller.postMessage({
|
||||
type: 'CLEAR_URL_MAPPINGS',
|
||||
});
|
||||
}
|
||||
this.readyBlobs.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -774,7 +715,7 @@ class DownloadManagerClass {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
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
|
||||
|
||||
@ -20,15 +20,30 @@ export const parseJsonObject = <T>(value?: unknown, fallback?: T): T => {
|
||||
if (!value) return (fallback || ({} as T)) as T;
|
||||
|
||||
try {
|
||||
if (typeof value === 'string') {
|
||||
const parsed = JSON.parse(value);
|
||||
return (parsed || fallback || {}) as T;
|
||||
let result: unknown = value;
|
||||
|
||||
// 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') {
|
||||
return value as T;
|
||||
// Ensure we return an object, not a primitive
|
||||
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;
|
||||
} catch {
|
||||
return (fallback || ({} as T)) as T;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user