import 'dart:io'; import '../database/app_database.dart'; import '../models/download_models.dart'; import '../utils/app_logger.dart'; import '../utils/downloaded_version_match.dart'; import 'download_storage_service.dart'; import 'saf_storage_service.dart'; /// A downloaded copy resolved to a playable location, plus the version that is /// actually on disk — which can differ from the requested one when /// [resolveDownloadedVideoSource] was allowed to fall back. typedef DownloadedVideoSource = ({String path, int mediaIndex, String? mediaSourceId}); /// Single source of truth for "where is the playable copy of this downloaded /// row, and is it the version that was asked for". /// /// Returns null when the row cannot back playback: the download is not /// complete, it holds a different version than requested (unless /// [allowAnyDownloadedVersion]), it has no stored video path, or the stored /// file is no longer reachable. /// /// Version matching is strict by default so online flows keep streaming an /// explicitly requested non-downloaded version (issue #1440). With /// [allowAnyDownloadedVersion] the downloaded version is returned on mismatch /// instead — for offline flows where the alternative is failing outright. /// /// Callers own their own preconditions (profile ownership, how the row was /// looked up); this only judges the row itself. Future resolveDownloadedVideoSource( DownloadedMediaItem row, { int? requestedMediaIndex, String? requestedMediaSourceId, bool allowAnyDownloadedVersion = false, }) async { if (row.status != DownloadStatus.completed.index) { appLogger.d('Download not complete for ${row.globalKey}. Status: ${row.status}'); return null; } if (!downloadedVersionMatches( row, requestedMediaIndex: requestedMediaIndex, requestedMediaSourceId: requestedMediaSourceId, )) { if (!allowAnyDownloadedVersion) { appLogger.d( '[VersionTrace] Downloaded copy of ${row.globalKey} is version ${row.mediaIndex} ' '(source ${row.mediaSourceId}), but requested version $requestedMediaIndex ' '(source ${requestedMediaSourceId?.trim()}) — skipping offline', ); return null; } appLogger.d( '[VersionTrace] Requested version $requestedMediaIndex (source ${requestedMediaSourceId?.trim()}) ' 'is not downloaded — falling back to downloaded version ${row.mediaIndex} ' '(source ${row.mediaSourceId})', ); } final storedPath = row.videoFilePath; if (storedPath == null) { appLogger.d('Video file path is null for ${row.globalKey}'); return null; } final storageService = DownloadStorageService.instance; // Reachability, not just presence in the row. A removable SAF volume can be // unmounted — or its grant revoked — while the row still reads `completed`, // and a stale content:// URI is indistinguishable from a live one until the // player fails to open it (issue #2101). File paths may be stored relative, // so resolve them first; SAF URIs are already playable as written. final readablePath = await storageService.getReadablePath(storedPath); final isReachable = storageService.isSafUri(storedPath) ? await SafStorageService.ops.exists(storedPath, isDir: false) : await File(readablePath).exists(); if (!isReachable) { // Returning null is what lets the caller stream from the server instead. appLogger.w('Offline video file not reachable: $readablePath (stored as: $storedPath)'); return null; } appLogger.d('Found offline video: $readablePath'); return (path: readablePath, mediaIndex: row.mediaIndex, mediaSourceId: row.mediaSourceId); }