Skip to main content

Overview

The Player model manages video playback, tracking watch progress, handling subtitles, managing next video suggestions for binge watching, and integrating with library and Trakt.

Structure

pub struct Player {
    pub selected: Option<Selected>,
    pub video_params: Option<VideoParams>,
    pub meta_item: Option<ResourceLoadable<MetaItem>>,
    pub subtitles: Vec<ResourceLoadable<Vec<Subtitles>>>,
    pub next_video: Option<Video>,
    pub next_streams: Option<ResourceLoadable<Vec<Stream>>>,
    pub next_stream: Option<Stream>,
    pub stream: Option<Loadable<(StreamUrls, Stream<ConvertedStreamSource>), EnvError>>,
    pub series_info: Option<SeriesInfo>,
    pub library_item: Option<LibraryItem>,
    pub stream_state: Option<StreamItemState>,
    pub intro_outro: Option<IntroOutro>,
    pub watched: Option<WatchedBitField>,
    pub analytics_context: Option<AnalyticsContext>,
    // Internal tracking fields...
}

Selected

Defines what to play:
pub struct Selected {
    pub stream: Stream,
    pub stream_request: Option<ResourceRequest>,
    pub meta_request: Option<ResourceRequest>,
    pub subtitles_path: Option<ResourcePath>,
}
Fields:
  • stream - The selected stream to play
  • stream_request - Request that fetched the stream (for tracking)
  • meta_request - Request for metadata
  • subtitles_path - Path for subtitle resources

VideoParams

Provided by streaming server:
pub struct VideoParams {
    pub hash: Option<String>,      // OpenSubtitles hash
    pub size: Option<u64>,         // File size
    pub filename: Option<String>,  // Filename
}
Used for subtitle matching and intro/outro detection.

Playback Actions

Load Player

Msg::Action(Action::Load(ActionLoad::Player(selected)))
1

Set Selection

Updates selected stream and metadata requests
2

Load Metadata

Fetches MetaItem from addons
3

Convert Stream

Converts stream source to playable URLs
4

Load Library Item

Creates or updates LibraryItem
5

Calculate Next Video

Determines next episode for binge watching
6

Dismiss Notification

Removes library item notification if present

Time Changed

Msg::Action(Action::Player(ActionPlayer::TimeChanged {
    time,
    duration,
    device,
}))
Updates watch progress continuously:
match (&self.selected, &mut self.library_item) {
    (Some(Selected { stream_request: Some(request), .. }), Some(library_item)) => {
        library_item.state.last_watched = Some(E::now());
        
        // Switch to new video if needed
        if library_item.state.video_id != Some(video_id.to_owned()) {
            library_item.state.video_id = Some(video_id.to_owned());
            library_item.state.overall_time_watched += library_item.state.time_watched;
            library_item.state.time_watched = 0;
            library_item.state.flagged_watched = 0;
        } else {
            let time_watched = time.saturating_sub(library_item.state.time_offset);
            library_item.state.time_watched += time_watched;
            library_item.state.overall_time_watched += time_watched;
        }
        
        if time > &library_item.state.time_offset {
            library_item.state.time_offset = *time;
            library_item.state.duration = *duration;
        }
        
        // Mark as watched if threshold reached
        if library_item.state.flagged_watched == 0
            && library_item.state.time_watched as f64
                > library_item.state.duration as f64 * WATCHED_THRESHOLD_COEF
        {
            library_item.state.flagged_watched = 1;
            library_item.state.times_watched += 1;
            
            if let Some(watched_bit_field) = &self.watched {
                let mut watched = watched_bit_field.to_owned();
                watched.set_video(video_id, true);
                library_item.state.watched = Some(watched.into());
            }
        }
        
        push_to_library::<E>(&mut self.push_library_item_time, library_item)
    }
    _ => Effects::none().unchanged(),
}
The WATCHED_THRESHOLD_COEF is typically 0.7, meaning an item is marked as watched when 70% is viewed.

Seek

Msg::Action(Action::Player(ActionPlayer::Seek {
    time,
    duration,
    device,
}))
Updates position without incrementing watch time:
library_item.state.last_watched = Some(E::now());
library_item.state.time_offset = *time;
library_item.state.duration = *duration;

// Collect seek logs for intro/outro detection
if self.collect_seek_logs && library_item.r#type == "series" 
    && time < &PLAYER_IGNORE_SEEK_AFTER 
{
    self.seek_history.push(SeekLog {
        from: library_item.state.time_offset,
        to: *time,
    });
}

Paused Changed

Msg::Action(Action::Player(ActionPlayer::PausedChanged { paused }))
Tracks pause state and emits Trakt events:
self.paused = Some(*paused);

let event = if !self.loaded {
    self.loaded = true;
    Event::PlayerPlaying { load_time, context }
} else if *paused {
    Event::TraktPaused { context }
} else {
    Event::TraktPlaying { context }
};

Library Updates

Push to Library

Changes are pushed to the library bucket every 90 seconds:
pub static PUSH_TO_LIBRARY_EVERY: Duration = Duration::seconds(90);

fn push_to_library<E: Env + 'static>(
    push_library_item_time: &mut DateTime<Utc>,
    library_item: &mut LibraryItem,
) -> Effects {
    if E::now() - *push_library_item_time >= *PUSH_TO_LIBRARY_EVERY {
        *push_library_item_time = E::now();
        
        Effects::msg(Msg::Internal(Internal::UpdateLibraryItem(
            library_item.to_owned(),
        )))
    } else {
        Effects::none().unchanged()
    }
}
On player unload, the library item is pushed immediately to ensure progress is saved.

Next Video (Binge Watching)

Calculation

Determines the next episode in a series:
fn next_video_update(
    video: &mut Option<Video>,
    stream: &Option<Stream>,
    selected: &Option<Selected>,
    meta_item: &Option<ResourceLoadable<MetaItem>>,
) -> Effects {
    let next_video = match (selected, meta_item) {
        (
            Some(Selected { stream_request: Some(request), .. }),
            Some(ResourceLoadable { content: Some(Loadable::Ready(meta)), .. }),
        ) => {
            meta.videos
                .iter()
                .find_position(|v| v.id == request.path.id)
                .and_then(|(position, current_video)| {
                    meta.videos.get(position + 1)
                        .map(|next| (current_video, next))
                })
                .filter(|(current, next)| {
                    let current_season = current.series_info
                        .as_ref()
                        .map(|info| info.season)
                        .unwrap_or_default();
                    let next_season = next.series_info
                        .as_ref()
                        .map(|info| info.season)
                        .unwrap_or_default();
                    
                    // Allow next video if:
                    // - Next season is not 0 (specials), OR
                    // - Both are in the same season
                    next_season != 0 || current_season == next_season
                })
                .map(|(_, next_video)| {
                    let mut next = next_video.clone();
                    if let Some(stream) = stream {
                        next.streams = vec![stream.clone()];
                    }
                    next
                })
        }
        _ => None,
    };
    
    eq_update(video, next_video)
}

Next Streams

Loads streams for the next episode:
fn next_streams_update<E>(
    next_streams: &mut Option<ResourceLoadable<Vec<Stream>>>,
    next_video: &Option<Video>,
    selected: &Option<Selected>,
) -> Effects
where E: Env + 'static
{
    match next_video {
        Some(next_video) => {
            let mut stream_request = selected
                .as_ref()
                .and_then(|s| s.stream_request.as_ref())
                .cloned()?;
            
            stream_request.path.id = next_video.id.clone();
            
            resource_update_with_vector_content::<E, _>(
                next_streams.get_or_insert_with(|| ResourceLoadable {
                    request: stream_request.clone(),
                    content: None,
                }),
                ResourceAction::ResourceRequested { request: &stream_request },
            )
        }
        None => Effects::none().unchanged(),
    }
}

Next Stream

Selects matching stream from next video:
fn next_stream_update(
    stream: &mut Option<Stream>,
    next_streams: &Option<ResourceLoadable<Vec<Stream>>>,
    selected: &Option<Selected>,
) -> Effects {
    let next_stream = match (selected, next_streams) {
        (
            Some(Selected { stream, .. }),
            Some(ResourceLoadable {
                content: Some(Loadable::Ready(streams)),
                ..
            }),
        ) => streams
            .iter()
            .find(|next_stream| next_stream.is_binge_match(stream))
            .cloned(),
        _ => None,
    };
    
    eq_update(stream, next_stream)
}

Subtitles

Loads subtitles from addons:
fn subtitles_update<E: Env + 'static>(
    subtitles: &mut Vec<ResourceLoadable<Vec<Subtitles>>>,
    selected: &Option<Selected>,
    video_params: &Option<VideoParams>,
    addons: &[Descriptor],
) -> Effects {
    match (selected, video_params) {
        (
            Some(Selected { subtitles_path: Some(path), .. }),
            Some(params),
        ) => {
            resources_update_with_vector_content::<E, _>(
                subtitles,
                ResourcesAction::force_request(
                    &AggrRequest::AllOfResource(ResourcePath {
                        extra: path.extra
                            .extend_one(&VIDEO_HASH_EXTRA_PROP, params.hash.clone())
                            .extend_one(&VIDEO_SIZE_EXTRA_PROP, params.size.map(|s| s.to_string()))
                            .extend_one(&VIDEO_FILENAME_EXTRA_PROP, params.filename.clone()),
                        ..path.clone()
                    }),
                    addons,
                ),
            )
        }
        _ => eq_update(subtitles, vec![]),
    }
}

Intro & Outro Detection

Skip Gaps

Requests intro/outro timestamps from API:
pub struct SkipGapsRequest {
    pub meta_item_type: String,
    pub meta_item_id: String,
    pub video_id: String,
    pub filename: Option<String>,
}

pub struct SkipGapsResponse {
    pub intro: Option<IntroData>,
    pub outro: Option<IntroData>,
}

pub struct IntroData {
    pub start: f64,
    pub end: f64,
}

Seek Logs

Collects user skip behavior for intro detection:
pub struct SeekLog {
    pub from: u64,
    pub to: u64,
}
Collection:
if self.collect_seek_logs 
    && library_item.r#type == "series" 
    && time < &PLAYER_IGNORE_SEEK_AFTER 
{
    self.seek_history.push(SeekLog {
        from: library_item.state.time_offset,
        to: *time,
    });
}
Submission: Sent when:
  • Player unloads
  • NextVideo action is triggered (skip outro)

Analytics

Context

pub struct AnalyticsContext {
    pub id: Option<String>,
    pub r#type: Option<String>,
    pub name: Option<String>,
    pub video_id: Option<String>,
    pub time: Option<u64>,
    pub duration: Option<u64>,
    pub device_type: Option<String>,
    pub device_name: Option<String>,
    pub player_duration: Option<u64>,
    pub player_video_width: u64,
    pub player_video_height: u64,
    pub has_trakt: bool,
}

Events

Event::PlayerPlaying { load_time, context }
Event::PlayerEnded { context, is_binge_enabled, is_playing_next_video }
Event::PlayerNextVideo { context, is_binge_enabled, is_playing_next_video }
Event::PlayerStopped { context }
Event::TraktPlaying { context }
Event::TraktPaused { context }

Usage Example

use stremio_core::models::player::{Player, Selected};
use stremio_core::types::resource::Stream;

// Load player with selected stream
let selected = Selected {
    stream: stream.clone(),
    stream_request: Some(stream_request.clone()),
    meta_request: Some(meta_request.clone()),
    subtitles_path: Some(ResourcePath {
        resource: "subtitles".to_string(),
        r#type: "movie".to_string(),
        id: "tt1254207".to_string(),
        extra: vec![],
    }),
};

runtime.dispatch(Msg::Action(
    Action::Load(ActionLoad::Player(Box::new(selected)))
));

// Update playback time (called frequently by video player)
runtime.dispatch(Msg::Action(
    Action::Player(ActionPlayer::TimeChanged {
        time: 300000,  // 5 minutes in milliseconds
        duration: 7200000,  // 2 hours
        device: "web".to_string(),
    })
));

// Skip to next episode
if player.next_video.is_some() {
    runtime.dispatch(Msg::Action(
        Action::Player(ActionPlayer::NextVideo)
    ));
}

Best Practices

Library item updates are batched every 90 seconds during playback to reduce API calls and storage writes.
Enable collect_seek_logs to gather data for intro detection. Seek logs are only collected for series and before a threshold time.
The player automatically calculates next_video and preloads next_streams to enable seamless episode transitions.

Build docs developers (and LLMs) love