Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1512,10 +1512,10 @@ packages:
dependency: transitive
description:
name: tpstreams_player_sdk
sha256: "0a5e713b1d7e0b901479ded395862c2da6628ff2c09085b75a4c352e4d36d629"
sha256: f2c36e123b7a787443a91bd634bbdc5b900c7c94bc35ee5d6463a689495f7acf
url: "https://pub.dev"
source: hosted
version: "2.2.22"
version: "2.2.26"
tuple:
dependency: transitive
description:
Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/video-watermark/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-27
33 changes: 33 additions & 0 deletions openspec/changes/video-watermark/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## Context

The video player currently does not apply any watermarks to identify users. We need to integrate a watermarking system that reads configuration fields (`type` and `position`) from the backend and overlays the user's username onto the video playback screen. This deters unauthorized recording and distribution of proprietary content.

## Goals / Non-Goals

**Goals:**
- Consume the watermark configuration provided by the backend.
- Implement watermarking natively using the `tpstreams_player_sdk`'s built-in `setWatermarks` API.
- Support `static` (fixed position), `dynamic` (animated/moving), and `hidden` configurations.
- Map the 5 predefined static positions (top left, top right, bottom left, bottom right, and middle) to the SDK's `x` and `y` percentage coordinate system.

**Non-Goals:**
- Implementing custom Flutter UI stacks to render watermarks over the video. We will strictly use the player SDK's capabilities.
- Modifying backend API logic or database schemas (this is purely consuming the frontend data).

## Decisions

- **Watermark Implementation:** We will use `_controller.setWatermarks([WatermarkConfig(...)])` provided by the player SDK. This ensures optimal performance and native integration with the video surface.
- **Dynamic Animation:** If the backend `type` is `dynamic`, we will configure the `WatermarkConfig` with a `WatermarkAnimation` using `WatermarkAnimationType.pingPong` and a reasonable duration (e.g., 5000ms or 10000ms).
- **Static Positioning Mapping:** The 5 backend positions for `static` mode will be mapped to the `x` and `y` properties (0-100 percentage values), using 10% and 90% as padded bounds:
- Top Left: `x: 10, y: 10`
- Top Right: `x: 90, y: 10`
- Bottom Left: `x: 10, y: 90`
- Bottom Right: `x: 90, y: 90`
- Middle: `x: 50, y: 50`
- **Opacity and Styling:** We will pass a standard `opacity` (e.g., 0.5) and white `color` (`0xFFFFFFFF`) to keep it visible but unobtrusive.
- **Config Syncing & Reactivity:** The implementation is highly reactive; if the `instituteSettingsProvider` or `userProvider` updates in the background during active playback, the player will instantly apply the new watermark configuration (e.g., updating position, type, or username text). Note: in the rare edge case where a configuration is explicitly disabled (changed to `null`) mid-session, the disable will seamlessly take effect on the next video launch rather than abruptly clearing the active native overlay.

## Risks / Trade-offs

- **Risk:** SDK-specific limitations on watermark styling compared to custom Flutter widgets.
- **Mitigation:** The native `WatermarkConfig` supports opacity, size, color, and animations, which is sufficient for our security and UI requirements while guaranteeing better performance than a custom Flutter overlay stack.
24 changes: 24 additions & 0 deletions openspec/changes/video-watermark/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## Why

We need to protect our proprietary video media from unauthorized distribution and recording. Extending configurable, user-identifying watermarks to the video player provides consistent intellectual property protection.

## What Changes

- Integrate watermarking infrastructure specifically into the video playback system.
- Support dynamic, backend-driven configuration for watermark rendering, handling three main types:
- `static`: Displayed in one of 5 fixed positions (top-left, top-right, bottom-left, bottom-right, middle).
- `dynamic`: Animated to move around the screen to prevent easy removal.
- `hidden`: Disables the watermark entirely.
- Ensure the video player securely overlays the user's username based on these settings without negatively impacting video performance or user experience.

## Capabilities

### New Capabilities
- `video-playback-watermark`: Applies configurable user-specific watermarks over video content during playback.

### Modified Capabilities

## Impact

- Video player UI components (rendering the overlay).
- Data and network models (parsing the `type` and `position` fields from the backend response).
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## ADDED Requirements

### Requirement: Configure video watermark from backend data
The system SHALL interpret backend watermark configuration containing `type` and `position` to determine the display properties of the video watermark.

#### Scenario: Type is hidden
- **WHEN** the backend returns the `hidden` type
- **THEN** no watermark is displayed on the video player

#### Scenario: Type is static
- **WHEN** the backend returns the `static` type with a valid position
- **THEN** the watermark is displayed at the fixed position corresponding to the backend data

#### Scenario: Type is dynamic
- **WHEN** the backend returns the `dynamic` type
- **THEN** the watermark is animated using a ping-pong movement across the screen

### Requirement: Static watermark positioning
When the watermark type is static, the system MUST map the string position to exact coordinates for the player SDK.

#### Scenario: Position mapping
- **WHEN** the static position is provided
- **THEN** it maps top-left to (0,0), top-right to (100,0), bottom-left to (0,100), bottom-right to (100,100), and middle to (50,50)

### Requirement: Watermark data content
The watermark MUST display the current user's identifying information.

#### Scenario: User identifying text
- **WHEN** the watermark is configured for display
- **THEN** the text displayed SHALL be the current user's username (falling back to the literal string 'user' if unavailable)
16 changes: 16 additions & 0 deletions openspec/changes/video-watermark/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
## 1. Data Integration & Setup

- [x] 1.1 Extract the `type` and `position` configuration for the watermark from the backend API response.
- [x] 1.2 Retrieve the current user's username to use as the text for the watermark.

## 2. Configuration Mapping

- [x] 2.1 Implement the mapping logic for the `static` type, converting the 5 predefined position strings (top left, top right, bottom left, bottom right, middle) into exact `x` and `y` percentage coordinates for the SDK.
- [x] 2.2 Implement the mapping logic for the `dynamic` type, configuring it to use `WatermarkAnimationType.pingPong` with an appropriate duration.
- [x] 2.3 Handle the `hidden` type logic to ensure no watermark is displayed.

## 3. Player Integration

- [x] 3.1 Construct the `WatermarkConfig` instance using the mapped properties, text, a standard opacity (e.g., 0.5), and a white color code.
- [x] 3.2 Pass the configured list of watermarks to the player SDK via `_controller.setWatermarks()`.
- [x] 3.3 Test and verify that the watermark behaves correctly according to the backend configuration for static, dynamic, and hidden modes.
49 changes: 48 additions & 1 deletion packages/core/lib/data/config/institute_settings.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import 'package:flutter/widgets.dart';
import 'package:flutter/foundation.dart';

/// Defines the allowed login methods for an institute.
enum LoginMethod { formLogin, socialLogin, otpLogin }

enum VideoWatermarkType { dynamic, static }

enum VideoWatermarkPosition {
topLeft,
topRight,
bottomLeft,
bottomRight,
middle,
}

@immutable
class InstituteSettings {
// APP Specific
Expand Down Expand Up @@ -51,6 +61,8 @@ class InstituteSettings {

//Security Settings
final bool allowScreenshotInApp;
final VideoWatermarkType? videoWatermarkType;
final VideoWatermarkPosition? videoWatermarkPosition;

//Store Settings
final bool storeEnabled;
Expand Down Expand Up @@ -91,9 +103,18 @@ class InstituteSettings {
required this.storeEnabled,
required this.storeLabel,
required this.currentPaymentApp,
this.videoWatermarkType,
this.videoWatermarkPosition,
});

factory InstituteSettings.fromJson(Map<String, dynamic> json) {
final watermarkType = (json['video_watermark_type'] as String?)
?.trim()
.toLowerCase();
final watermarkPosition = (json['video_watermark_position'] as String?)
?.trim()
.toLowerCase();

return InstituteSettings(
name: json['name'] as String? ?? '',
photo: json['photo'] as String? ?? '',
Expand Down Expand Up @@ -149,6 +170,19 @@ class InstituteSettings {
storeEnabled: json['store_enabled'] as bool? ?? false,
storeLabel: json['store_label'] as String? ?? 'Store',
currentPaymentApp: json['current_payment_app'] as String? ?? '',
videoWatermarkType: switch (watermarkType) {
'dynamic' => VideoWatermarkType.dynamic,
'static' => VideoWatermarkType.static,
_ => null,
},
videoWatermarkPosition: switch (watermarkPosition) {
'top left' => VideoWatermarkPosition.topLeft,
'top right' => VideoWatermarkPosition.topRight,
'bottom left' => VideoWatermarkPosition.bottomLeft,
'bottom right' => VideoWatermarkPosition.bottomRight,
'middle' => VideoWatermarkPosition.middle,
_ => null,
},
);
}

Expand Down Expand Up @@ -196,6 +230,19 @@ class InstituteSettings {
'store_enabled': storeEnabled,
'store_label': storeLabel,
'current_payment_app': currentPaymentApp,
'video_watermark_type': switch (videoWatermarkType) {
VideoWatermarkType.dynamic => 'dynamic',
VideoWatermarkType.static => 'static',
null => null,
},
'video_watermark_position': switch (videoWatermarkPosition) {
VideoWatermarkPosition.topLeft => 'top left',
VideoWatermarkPosition.topRight => 'top right',
VideoWatermarkPosition.bottomLeft => 'bottom left',
VideoWatermarkPosition.bottomRight => 'bottom right',
VideoWatermarkPosition.middle => 'middle',
null => null,
},
};
}
}
2 changes: 1 addition & 1 deletion packages/core/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ dependencies:
dio: ^5.9.2
flutter_secure_storage: ^9.2.2
dio_web_adapter: ^2.1.2
tpstreams_player_sdk: 2.2.22
tpstreams_player_sdk: 2.2.26
smooth_page_indicator: ^1.1.0
permission_handler: ^12.0.1
skeletonizer: ^2.1.3
Expand Down
28 changes: 28 additions & 0 deletions packages/core/test/data/config/institute_settings_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:core/data/config/institute_settings.dart';

void main() {
group('InstituteSettings.fromJson', () {
test('parses video watermark fields correctly', () {
final json = {
'name': 'Test Institute',
'video_watermark_type': 'Dynamic',
'video_watermark_position': 'top left',
};

final settings = InstituteSettings.fromJson(json);

expect(settings.videoWatermarkType, VideoWatermarkType.dynamic);
expect(settings.videoWatermarkPosition, VideoWatermarkPosition.topLeft);
});

test('handles missing video watermark fields gracefully', () {
final json = {'name': 'Test Institute'};

final settings = InstituteSettings.fromJson(json);

expect(settings.videoWatermarkType, isNull);
expect(settings.videoWatermarkPosition, isNull);
});
});
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/courses/lib/providers/downloads_provider.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import 'package:core/data/data.dart';
import 'package:core/core.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tpstreams_player_sdk/tpstreams_player_sdk.dart';

class VideoWatermarkConfigFactory {
static WatermarkConfig? create(
VideoWatermarkType? type,
VideoWatermarkPosition? position,
String watermarkText,
double fontSize,
) {
return switch (type) {
null => null,
VideoWatermarkType.dynamic => WatermarkConfig(
text: watermarkText,
opacity: 0.5,
textSize: fontSize,
y: 50,
animation: WatermarkAnimation(
type: WatermarkAnimationType.pingPong,
),
),
VideoWatermarkType.static => WatermarkConfig(
text: watermarkText,
opacity: 0.5,
textSize: fontSize,
x: _resolveCoordinates(position).x,
y: _resolveCoordinates(position).y,
),
};
}

static ({int x, int y}) _resolveCoordinates(
VideoWatermarkPosition? position) {
return switch (position) {
VideoWatermarkPosition.topLeft => (x: 10, y: 10),
VideoWatermarkPosition.topRight => (x: 90, y: 10),
VideoWatermarkPosition.bottomLeft => (x: 10, y: 90),
VideoWatermarkPosition.bottomRight => (x: 90, y: 90),
VideoWatermarkPosition.middle || null => (x: 50, y: 50),
};
}
}

final videoWatermarkConfigProvider =
Provider.family<WatermarkConfig?, double>((ref, fontSize) {
final settings = ref.watch(instituteSettingsProvider);
if (settings?.videoWatermarkType == null) {
return null;
}

final userAsync = ref.watch(userProvider);
if (userAsync.isLoading && !userAsync.hasValue) {
return null;
}

final user = userAsync.value;
String watermarkText = user?.username ?? '';
if (watermarkText.trim().isEmpty) {
watermarkText = 'user';
}

return VideoWatermarkConfigFactory.create(
settings?.videoWatermarkType,
settings?.videoWatermarkPosition,
watermarkText,
fontSize,
);
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import 'package:core/design/design_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tpstreams_player_sdk/tpstreams_player_sdk.dart';
import 'package:core/data/data.dart';

import '../../providers/course_list_provider.dart';
import '../../providers/video_attempt_provider.dart';
import '../../providers/video_watermark_config_provider.dart';

class CustomVideoPlayer extends ConsumerStatefulWidget {
final String? assetId;
Expand Down Expand Up @@ -198,6 +200,17 @@ class CustomVideoPlayerState extends ConsumerState<CustomVideoPlayer> {
void _onPlayerCreated(TestpressPlayerController controller) {
_controller = controller;

final design = Design.of(context);
final fontSize = design.typography.headline.fontSize ?? 14.0;
final watermarkConfig = ref.read(videoWatermarkConfigProvider(fontSize));

if (watermarkConfig != null) {
controller.setWatermarks([watermarkConfig]).catchError((e, st) {
if (!mounted) return;
ref.read(sentryServiceProvider).captureException(e, stackTrace: st);
});
}

controller.addListener(() {
final isPlaying = controller.value.isPlaying;
final currentPos = controller.value.position.inMilliseconds / 1000.0;
Expand Down
2 changes: 1 addition & 1 deletion packages/courses/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ dependencies:
drift: ^2.21.0
intl: ^0.20.2
cached_network_image: ^3.4.1
tpstreams_player_sdk: 2.2.22
tpstreams_player_sdk: 2.2.26
package_info_plus: ^8.2.1
syncfusion_flutter_pdfviewer: ^33.1.45
syncfusion_flutter_pdf: ^33.1.45
Expand Down
Loading