I’m developing a custom video player interface using Shaka Player for adaptive streaming. The unique requirement is to handle server-sided seeking, where any seek action by the user triggers an API call to the server. The server processes this request and ensures that Shaka Player always requests segment 0 upon seeking. The server maps segment 0 to the desired playback position internally.
The server will set the video to playback at the given timestamp sucessfully when requested but the player must start with segment 0 just like how the video player first loads the video.
Objectives:
- Trigger API on Seek: When a user seeks to a new timestamp, an API call is made to inform the server of the desired position.
- Reload Video at Segment 0: After the server processes the seek, Shaka Player should reload the video starting from segment 0.
- Maintain Full Duration Display: The player’s seek bar should reflect the full duration of the video, not just the buffered segments.
- Seamless User Experience: The seek operation should feel smooth, without noticeable delays or playback interruptions.
- Update Seek Bar Position: After seeking, the seek bar should accurately represent the new playback position.
Issues encountered:
- When seeking, it makes the api request then followed by successfully getting segment 0 for video and audio but the problem is Shaka makes the request of segment something-hundred which fails as it doesn’t exist.
- I tried other ways and another issue I come across is the seek bar resets to the start and the duration of the video is the remainder of the video.
How can I effectively implement server-sided seeking in Shaka Player with a custom UI, ensuring that:
Any seek action triggers an API call to the server.
Shaka Player reloads the video starting from segment 0.
The seek bar displays the full duration and accurately reflects the new playback position.
The entire process is seamless, without noticeable delays (excluding the small time for get requests) or playback interruptions.
class DashSegmentHelper {
constructor(manifestXml) {
this.manifestXml = manifestXml;
this.manifest = this.parseManifest(manifestXml);
this.segmentInfo = this.extractSegmentInfo();
this.duration = this.parseDuration(this.manifest.mediaPresentationDuration);
this.baseUrl = this.extractBaseUrl();
if (DashSegmentHelper.initialTotalDuration === undefined) {
DashSegmentHelper.initialTotalDuration = this.duration;
}
this.totalDuration = DashSegmentHelper.initialTotalDuration;
}
extractBaseUrl() {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(this.manifestXml, "text/xml");
const baseUrl = xmlDoc.querySelector('BaseURL');
return baseUrl ? baseUrl.textContent : '';
}
parseManifest(manifestXml) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(manifestXml, "text/xml");
const mpd = xmlDoc.getElementsByTagName('MPD')[0];
if (!mpd) throw new Error('No MPD element found');
const period = xmlDoc.getElementsByTagName('Period')[0];
if (!period) throw new Error('No Period element found');
return {
type: mpd.getAttribute('type'),
minBufferTime: mpd.getAttribute('minBufferTime'),
mediaPresentationDuration: mpd.getAttribute('mediaPresentationDuration'),
periodStart: period.getAttribute('start'),
periodDuration: period.getAttribute('duration')
};
}
extractSegmentInfo() {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(this.manifestXml, "text/xml");
const segmentTemplates = xmlDoc.getElementsByTagName('SegmentTemplate');
const videoTemplate = this.findVideoSegmentTemplate(segmentTemplates);
if (!videoTemplate) throw new Error('No valid segment template found');
return {
timescale: parseInt(videoTemplate.getAttribute('timescale')),
duration: parseInt(videoTemplate.getAttribute('duration')),
startNumber: parseInt(videoTemplate.getAttribute('startNumber')) || 1,
initializationTemplate: videoTemplate.getAttribute('initialization'),
mediaTemplate: videoTemplate.getAttribute('media')
};
}
findVideoSegmentTemplate(templates) {
for (const template of templates) {
const parent = template.parentElement;
if (parent?.getAttribute('mimeType')?.includes('video')) {
return template;
}
}
return templates[0] || null;
}
parseDuration(isoDuration) {
const regex = /PT(?:(d+)H)?(?:(d+)M)?(?:(d+)S)?/;
const matches = isoDuration.match(regex);
if (!matches) return 0;
const [_, hours = 0, minutes = 0, seconds = 0] = matches;
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
}
getBufferConfig() {
return {
minBufferTime: this.parseDuration(this.manifest.minBufferTime),
segmentDuration: this.segmentInfo.duration / this.segmentInfo.timescale,
totalDuration: this.totalDuration
};
}
}
DashSegmentHelper.initialTotalDuration = undefined;
class EnhancedVideoPlayer {
constructor() {
this.video = document.getElementById('videoPlayer');
this.loadingIndicator = document.getElementById('loadingIndicator');
this.loadingText = document.getElementById('loadingText');
this.bufferInfo = document.getElementById('bufferInfo');
this.debugInfo = document.getElementById('debugInfo');
this.seekPreview = document.getElementById('seekPreview');
this.player = null;
this.segmentHelper = null;
// Seeking and buffering state
this.seekOperationInProgress = false;
this.isSeekInProgress = false;
this.lastSeekTime = 0;
this.currentSegmentNumber = 0;
this.isInitialSegmentLoaded = false;
this.segmentsToBuffer = new Set();
// Buffer monitoring
this.lastBufferUpdate = 0;
this.bufferingInProgress = false;
this.currentBufferGoal = 30;
this.bufferInterval = null;
this.initialize();
}
async initialize() {
try {
shaka.polyfill.installAll();
this.player = new shaka.Player(this.video);
await this.setupNetworkFilters();
this.configurePlayer();
this.setupEventListeners();
await this.loadVideo();
this.startBufferMonitoring();
} catch (error) {
console.error('Player initialization error:', error);
this.updateBufferInfo('Error: ' + error.message);
}
}
setupNetworkFilters() {
const networkingEngine = this.player.getNetworkingEngine();
if (!networkingEngine) return;
networkingEngine.registerRequestFilter((type, request) => {
if (type === shaka.net.NetworkingEngine.RequestType.SEGMENT) {
const url = new URL(request.uris[0]);
request.originalUri = request.uris[0];
const segmentMatch = url.toString().match(/segment[_-](d+)/i);
if (segmentMatch && this.isSeekInProgress) {
const segmentNumber = parseInt(segmentMatch[1]);
if (!this.isInitialSegmentLoaded) {
// First segment after seek - use segment-0
const newUrl = url.toString().replace(
/segment[_-]d+/i,
'segment-0'
);
url.href = newUrl;
url.searchParams.set('t', Math.floor(this.lastSeekTime));
this.isInitialSegmentLoaded = true;
this.currentSegmentNumber = 1;
} else {
// Subsequent segments - use incrementing numbers
const newUrl = url.toString().replace(
/segment[_-]d+/i,
`segment-${this.currentSegmentNumber}`
);
url.href = newUrl;
this.currentSegmentNumber++;
}
request.uris[0] = url.toString();
console.log('Segment request:', {
original: request.originalUri,
modified: request.uris[0],
isInitial: !this.isInitialSegmentLoaded
});
}
}
});
networkingEngine.registerResponseFilter((type, response) => {
if (type === shaka.net.NetworkingEngine.RequestType.SEGMENT) {
if (response.request?.uris[0]) {
const segmentMatch = response.request.uris[0].match(/segment[_-](d+)/i);
if (segmentMatch) {
const segmentNumber = parseInt(segmentMatch[1]);
this.segmentsToBuffer.delete(segmentNumber);
// End seek state when all required segments are buffered
if (this.isSeekInProgress && this.segmentsToBuffer.size === 0) {
this.isSeekInProgress = false;
this.isInitialSegmentLoaded = false;
console.log('All required segments buffered, ending seek state');
}
}
}
}
});
}
configurePlayer() {
this.player.configure({
streaming: {
bufferingGoal: this.currentBufferGoal,
rebufferingGoal: 15,
bufferBehind: 30,
stallEnabled: false,
stallSkip: 0.1,
retryParameters: {
maxAttempts: 2,
baseDelay: 500,
backoffFactor: 1.5,
fuzzFactor: 0.5
}
},
abr: {
enabled: true,
defaultBandwidthEstimate: 50000000,
switchInterval: 8
}
});
}
setupEventListeners() {
this.player.addEventListener('error', this.handlePlayerError.bind(this));
this.player.addEventListener('buffering', this.handleBuffering.bind(this));
this.player.addEventListener('variantchanged', () => this.updateDebugInfo());
this.player.addEventListener('trackschanged', () => this.updateDebugInfo());
this.video.addEventListener('seeking', this.handleSeeking.bind(this));
this.video.addEventListener('seeked', this.handleSeeked.bind(this));
this.video.addEventListener('waiting', this.handleWaiting.bind(this));
this.video.addEventListener('playing', this.handlePlaying.bind(this));
window.addEventListener('beforeunload', () => {
if (this.segmentsToBuffer.size > 0) {
this.segmentsToBuffer.clear();
if (this.player.getNetworkingEngine()) {
this.player.getNetworkingEngine().clear();
}
}
});
}
async handleSeeking() {
if (this.seekOperationInProgress) return;
this.seekOperationInProgress = true;
this.isSeekInProgress = true;
this.isInitialSegmentLoaded = false;
this.lastSeekTime = this.video.currentTime;
this.currentSegmentNumber = 0;
// Reset segment tracking
this.segmentsToBuffer.clear();
// Calculate required segments for initial buffer
const segmentDuration = this.segmentHelper?.getBufferConfig().segmentDuration || 2;
const initialBufferSeconds = 30;
const segmentsNeeded = Math.ceil(initialBufferSeconds / segmentDuration);
for (let i = 0; i < segmentsNeeded; i++) {
this.segmentsToBuffer.add(i);
}
this.loadingIndicator.style.display = 'block';
this.loadingText.textContent = 'Seeking to ' + this.formatTime(this.lastSeekTime);
try {
let mpdUrl = new URL(window.DASH_MPD_LINK);
mpdUrl.searchParams.set('t', Math.floor(this.lastSeekTime));
await this.player.unload();
const response = await fetch(mpdUrl.toString());
const manifestXml = await response.text();
this.segmentHelper = new DashSegmentHelper(manifestXml);
await this.player.load(mpdUrl.toString(), this.lastSeekTime);
} catch (error) {
console.error('Seek error:', error);
this.updateBufferInfo('Seek error: ' + error.message);
this.seekOperationInProgress = false;
this.isSeekInProgress = false;
}
}
handleSeeked() {
this.seekOperationInProgress = false;
this.loadingIndicator.style.display = 'none';
this.updateDebugInfo();
}
handlePlayerError(event) {
console.error('Player error:', event.detail);
this.updateBufferInfo('Error: ' + event.detail.message);
}
handleBuffering(event) {
this.bufferingInProgress = event.buffering;
this.loadingIndicator.style.display = event.buffering ? 'block' : 'none';
if (event.buffering) {
this.loadingText.textContent = 'Buffering...';
}
}
handleWaiting() {
if (!this.bufferingInProgress) {
this.loadingIndicator.style.display = 'block';
this.loadingText.textContent = 'Loading...';
}
}
handlePlaying() {
this.loadingIndicator.style.display = 'none';
}
async loadVideo(seekTime = 0) {
try {
let mpdUrl = new URL(window.DASH_MPD_LINK);
if (seekTime > 0) {
mpdUrl.searchParams.set('t', Math.floor(seekTime));
}
const response = await fetch(mpdUrl.toString());
const manifestXml = await response.text();
this.segmentHelper = new DashSegmentHelper(manifestXml);
await this.player.load(mpdUrl.toString(), seekTime);
} catch (error) {
console.error('Error loading video:', error);
this.updateBufferInfo('Failed to load video: ' + error.message);
throw error;
}
}
startBufferMonitoring() {
this.bufferInterval = setInterval(() => {
if (!this.seekOperationInProgress && !this.video.paused) {
this.updateBufferInfo();
this.updateDebugInfo();
}
}, 1000);
this.video.addEventListener('ended', () => {
if (this.bufferInterval) {
clearInterval(this.bufferInterval);
}
});
}
updateBufferInfo() {
if (!this.segmentHelper) return;
const now = Date.now();
if (now - this.lastBufferUpdate < 1000) return;
this.lastBufferUpdate = now;
const buffered = this.video.buffered;
let bufferStatus = 'Buffered ranges:n';
for (let i = 0; i < buffered.length; i++) {
const start = Math.floor(buffered.start(i));
const end = Math.floor(buffered.end(i));
bufferStatus += `[${this.formatTime(start)} - ${this.formatTime(end)}] `;
}
const stats = this.player.getStats();
bufferStatus += 'nBandwidth: ' + Math.round(stats.estimatedBandwidth / 1000000) + ' Mbps';
bufferStatus += 'nBuffer Ahead: ' + this.getBufferAhead() + ' s';
bufferStatus += 'nTotal Duration: ' + this.formatTime(this.segmentHelper.totalDuration);
if (this.isSeekInProgress) {
bufferStatus += 'nBuffering segments: ' + Array.from(this.segmentsToBuffer).join(', ');
}
this.bufferInfo.textContent = bufferStatus;
}
updateDebugInfo() {
if (!this.segmentHelper) return;
const stats = this.player.getStats();
const track = this.player.getVariantTracks().find(t => t.active);
const bufferConfig = this.segmentHelper.getBufferConfig();
const debugHTML = [
['Current Quality', track ? `${track.width}x${track.height}` : 'N/A'],
['Buffered Ahead', `${this.getBufferAhead()} s`],
['Estimated Bandwidth', `${Math.round(stats.estimatedBandwidth / 1000000)} Mbps`],
['Buffer Health', `${Math.round(stats.bufferingHealth * 100)}%`],
['Dropped Frames', stats.droppedFrames],
['Segment Duration', `${bufferConfig.segmentDuration.toFixed(2)} s`],
['Total Duration', this.formatTime(this.segmentHelper.totalDuration)],
['Seek State', this.isSeekInProgress ? 'Seeking' : 'Normal'],
['Current Segment', this.currentSegmentNumber],
['Segments to Buffer', this.segmentsToBuffer.size]
].map(([key, value]) => `<div><strong>${key}:</strong> ${value}</div>`);
this.debugInfo.innerHTML = debugHTML.join('');
}
getBufferAhead() {
const currentTime = this.video.currentTime;
const buffered = this.video.buffered;
for (let i = 0; i < buffered.length; i++) {
if (buffered.start(i) <= currentTime && currentTime <= buffered.end(i)) {
return Math.floor(buffered.end(i) - currentTime);
}
}
return 0;
}
formatTime(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
}
// Initialize player when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
const player = new EnhancedVideoPlayer();
// Add keyboard controls
document.addEventListener('keydown', (event) => {
const seekStep = 300; // 5 minutes in seconds
if (event.ctrlKey) {
if (event.key === 'ArrowRight') {
const newTime = Math.min(
player.video.currentTime + seekStep,
player.video.duration
);
player.video.currentTime = newTime;
} else if (event.key === 'ArrowLeft') {
const newTime = Math.max(
player.video.currentTime - seekStep,
0
);
player.video.currentTime = newTime;
}
}
});
// Add progress bar interactions
const progressBar = document.querySelector('.shaka-progress-container');
if (progressBar) {
progressBar.addEventListener('mousemove', (event) => {
const rect = progressBar.getBoundingClientRect();
const pos = (event.clientX - rect.left) / rect.width;
const timeInSeconds = player.segmentHelper?.totalDuration * pos || 0;
const preview = document.getElementById('seekPreview');
if (preview) {
preview.style.display = 'block';
preview.style.left = `${event.clientX}px`;
preview.textContent = player.formatTime(timeInSeconds);
}
});
progressBar.addEventListener('mouseleave', () => {
const preview = document.getElementById('seekPreview');
if (preview) {
preview.style.display = 'none';
}
});
progressBar.addEventListener('click', (event) => {
const rect = progressBar.getBoundingClientRect();
const pos = (event.clientX - rect.left) / rect.width;
const seekTime = player.segmentHelper?.totalDuration * pos || 0;
player.video.currentTime = seekTime;
});
}
});