import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../client/plex_client.dart'; import '../models/plex_media_info.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; import '../utils/app_logger.dart'; import '../i18n/strings.g.dart'; import 'track_selection_service.dart'; /// Service responsible for initializing video playback /// /// Handles the complex process of: /// 1. Fetching video playback data from the Plex server /// 2. Building external subtitle tracks /// 3. Opening media in the player /// 4. Adding external subtitles to the player /// 5. Selecting and applying audio/subtitle tracks /// 6. Seeking to resume position /// 7. Starting playback class PlaybackInitializationService { final Player player; final PlexClient client; final BuildContext context; PlaybackInitializationService({ required this.player, required this.client, required this.context, }); /// Start playback for the given metadata /// /// Returns a PlaybackInitializationResult with available versions and other data Future startPlayback({ required PlexMetadata metadata, required int selectedMediaIndex, required PlexUserProfile? profileSettings, AudioTrack? preferredAudioTrack, SubtitleTrack? preferredSubtitleTrack, double? preferredPlaybackRate, }) async { try { // Get consolidated playback data (URL, media info, and versions) in a single API call final playbackData = await client.getVideoPlaybackData( metadata.ratingKey, mediaIndex: selectedMediaIndex, ); if (!playbackData.hasValidVideoUrl) { throw PlaybackException(t.messages.fileInfoNotAvailable); } final videoUrl = playbackData.videoUrl!; final mediaInfo = playbackData.mediaInfo; // Build list of external subtitle tracks for media_kit final externalSubtitles = _buildExternalSubtitles(mediaInfo); // Open video (without external subtitles in Media constructor) await player.open(Media(videoUrl), play: false); // Wait for media to be ready (duration > 0) await _waitForMediaReady(); // Add external subtitle tracks without auto-selecting them if (externalSubtitles.isNotEmpty) { await _addExternalSubtitles(externalSubtitles); } // Select and apply tracks BEFORE seeking to resume position // This prevents track changes from resetting the playback position on Android final trackSelectionService = TrackSelectionService( player: player, profileSettings: profileSettings, metadata: metadata, ); await trackSelectionService.selectAndApplyTracks( preferredAudioTrack: preferredAudioTrack, preferredSubtitleTrack: preferredSubtitleTrack, preferredPlaybackRate: preferredPlaybackRate, ); // Set up playback position if resuming (AFTER track selection) if (metadata.viewOffset != null && metadata.viewOffset! > 0) { appLogger.d('Resuming playback at ${metadata.viewOffset} ms'); final resumePosition = Duration(milliseconds: metadata.viewOffset!); await player.seek(resumePosition); } // Start playback after seeking await player.play(); // Return result with available versions for UI updates return PlaybackInitializationResult( availableVersions: playbackData.availableVersions, ); } catch (e) { if (e is PlaybackException) { rethrow; } throw PlaybackException(t.messages.errorLoading(error: e.toString())); } } /// Build list of external subtitle tracks from media info List _buildExternalSubtitles(PlexMediaInfo? mediaInfo) { final externalSubtitles = []; if (mediaInfo == null) { return externalSubtitles; } final externalTracks = mediaInfo.subtitleTracks .where((PlexSubtitleTrack track) => track.isExternal) .toList(); if (externalTracks.isNotEmpty) { appLogger.d('Found ${externalTracks.length} external subtitle track(s)'); } for (final plexTrack in externalTracks) { try { // Skip if no auth token is available final token = client.config.token; if (token == null) { appLogger.w('No auth token available for external subtitles'); continue; } final url = plexTrack.getSubtitleUrl(client.config.baseUrl, token); // Skip if URL couldn't be constructed if (url == null) continue; externalSubtitles.add( SubtitleTrack.uri( url, title: plexTrack.displayTitle ?? plexTrack.language ?? 'Track ${plexTrack.id}', language: plexTrack.languageCode, ), ); } catch (e) { // Silent fallback - log error but continue with other subtitles appLogger.w( 'Failed to add external subtitle track ${plexTrack.id}', error: e, ); } } return externalSubtitles; } /// Wait for media to be ready (duration > 0) Future _waitForMediaReady() async { int attempts = 0; while (player.state.duration.inMilliseconds == 0 && attempts < 100) { await Future.delayed(const Duration(milliseconds: 100)); attempts++; } } /// Add external subtitle tracks to the player without auto-selecting them Future _addExternalSubtitles( List externalSubtitles, ) async { appLogger.d( 'Adding ${externalSubtitles.length} external subtitle(s) to player', ); final nativePlayer = player.platform as dynamic; for (final subtitleTrack in externalSubtitles) { try { // Use mpv's sub-add with 'auto' flag to avoid auto-selection await nativePlayer.command([ 'sub-add', subtitleTrack.id, 'auto', subtitleTrack.title ?? 'external', subtitleTrack.language ?? 'auto', ]); } catch (e) { appLogger.w( 'Failed to add external subtitle: ${subtitleTrack.title}', error: e, ); } } } } /// Result of playback initialization class PlaybackInitializationResult { final List availableVersions; PlaybackInitializationResult({required this.availableVersions}); } /// Exception thrown when playback initialization fails class PlaybackException implements Exception { final String message; PlaybackException(this.message); @override String toString() => message; }