Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

how to handle server-sided seeking within shaka player and overriding internal segment buffering and a keep a consistent seek bar? #7806

Open
5hihan opened this issue Dec 25, 2024 · 0 comments
Labels
type: question A question from the community

Comments

@5hihan
Copy link

5hihan commented Dec 25, 2024

Have you read the Tutorials?
Yes

Have you read the FAQ and checked for duplicate open issues?
Yes

If the question is related to FairPlay, have you read the tutorial?
n/a

What version of Shaka Player are you using?
4.2.3

What browser and OS are you using?
Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0
Windows 11

Please ask your question

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;
        });
    }
});
@5hihan 5hihan added the type: question A question from the community label Dec 25, 2024
@5hihan 5hihan changed the title how to handle server-sided seeking within shaka player and overriding internal segment buffering and a consistent seek bar? how to handle server-sided seeking within shaka player and overriding internal segment buffering and a keep a consistent seek bar? Dec 25, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: question A question from the community
Projects
None yet
Development

No branches or pull requests

1 participant