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 )))
Set Selection
Updates selected stream and metadata requests
Load Metadata
Fetches MetaItem from addons
Convert Stream
Converts stream source to playable URLs
Load Library Item
Creates or updates LibraryItem
Calculate Next Video
Determines next episode for binge watching
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
Throttled Library Updates
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.