mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
bug fixes
This commit is contained in:
parent
b21efa0df0
commit
d0719fabec
5 changed files with 315 additions and 45 deletions
|
|
@ -32,6 +32,7 @@ import {
|
|||
} from './utils/playerTypes';
|
||||
import { safeDebugLog, parseSRT, DEBUG_MODE, formatTime } from './utils/playerUtils';
|
||||
import { styles } from './utils/playerStyles';
|
||||
import { isMkvStream } from '../../utils/mkvDetection';
|
||||
import { SubtitleModals } from './modals/SubtitleModals';
|
||||
import { AudioTrackModal } from './modals/AudioTrackModal';
|
||||
// Removed ResumeOverlay usage when alwaysResume is enabled
|
||||
|
|
@ -48,9 +49,16 @@ const VideoPlayer: React.FC = () => {
|
|||
const { streamProvider, uri, headers, forceVlc } = route.params as any;
|
||||
|
||||
// Check if the stream is from Xprime (by provider name or URL pattern)
|
||||
const isXprimeStream = streamProvider === 'xprime' || streamProvider === 'Xprime' ||
|
||||
const isXprimeStream = streamProvider === 'xprime' || streamProvider === 'Xprime' ||
|
||||
(uri && /flutch.*\.workers\.dev|fsl\.fastcloud\.casa|xprime/i.test(uri));
|
||||
|
||||
safeDebugLog("Stream detection", {
|
||||
uri,
|
||||
streamProvider,
|
||||
isXprimeStream,
|
||||
platform: Platform.OS
|
||||
});
|
||||
|
||||
// Xprime-specific headers for better compatibility (from local-scrapers-repo)
|
||||
const getXprimeHeaders = () => {
|
||||
if (!isXprimeStream) return {};
|
||||
|
|
@ -69,21 +77,28 @@ const VideoPlayer: React.FC = () => {
|
|||
logger.log('[VideoPlayer] Applying Xprime headers for stream:', uri);
|
||||
return xprimeHeaders;
|
||||
};
|
||||
|
||||
// Check if the file format is MKV
|
||||
const lowerUri = (uri || '').toLowerCase();
|
||||
const contentType = (headers && (headers['Content-Type'] || headers['content-type'])) || '';
|
||||
const isMkvByHeader = typeof contentType === 'string' && contentType.includes('matroska');
|
||||
const isMkvByPath = lowerUri.includes('.mkv') || /[?&]ext=mkv\b/.test(lowerUri) || /format=mkv\b/.test(lowerUri) || /container=mkv\b/.test(lowerUri);
|
||||
const isMkvFile = Boolean(isMkvByHeader || isMkvByPath);
|
||||
|
||||
|
||||
// Detect if stream is MKV format
|
||||
const isMkvFile = isMkvStream(uri, headers);
|
||||
|
||||
// Use AndroidVideoPlayer for:
|
||||
// - Android devices
|
||||
// - Xprime streams on any platform
|
||||
// - Xprime streams (unless it's MKV on iOS - allow VLC for better compatibility)
|
||||
// - Non-MKV files on iOS (unless forceVlc is set)
|
||||
// Use VideoPlayer (VLC) for:
|
||||
// - MKV files on iOS (unless forceVlc is set)
|
||||
const shouldUseAndroidPlayer = Platform.OS === 'android' || isXprimeStream || (Platform.OS === 'ios' && !isMkvFile && !forceVlc);
|
||||
// - MKV files on iOS (unless forceVlc is set, even for Xprime)
|
||||
const shouldUseAndroidPlayer = Platform.OS === 'android' ||
|
||||
(isXprimeStream && !(Platform.OS === 'ios' && isMkvFile)) ||
|
||||
(Platform.OS === 'ios' && !isMkvFile && !forceVlc);
|
||||
|
||||
safeDebugLog("Player selection logic", {
|
||||
platform: Platform.OS,
|
||||
isXprimeStream,
|
||||
isMkvFile,
|
||||
forceVlc,
|
||||
xprimeException: isXprimeStream && Platform.OS === 'ios' && isMkvFile,
|
||||
shouldUseAndroidPlayer
|
||||
});
|
||||
if (shouldUseAndroidPlayer) {
|
||||
return <AndroidVideoPlayer />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,32 +21,33 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => {
|
|||
const [showUpdatePopup, setShowUpdatePopup] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo>({ isAvailable: false });
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [hasCheckedOnStartup, setHasCheckedOnStartup] = useState(false);
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
const checkForUpdates = useCallback(async (forceCheck = false) => {
|
||||
try {
|
||||
// Check if user has dismissed the popup for this version
|
||||
const dismissedVersion = await AsyncStorage.getItem(UPDATE_POPUP_STORAGE_KEY);
|
||||
const currentVersion = updateInfo.manifest?.id;
|
||||
|
||||
if (dismissedVersion === currentVersion) {
|
||||
|
||||
if (dismissedVersion === currentVersion && !forceCheck) {
|
||||
return; // User already dismissed this version
|
||||
}
|
||||
|
||||
// Check if user chose "later" recently (within 24 hours)
|
||||
// Check if user chose "later" recently (within 6 hours)
|
||||
const updateLaterTimestamp = await AsyncStorage.getItem(UPDATE_LATER_STORAGE_KEY);
|
||||
if (updateLaterTimestamp) {
|
||||
if (updateLaterTimestamp && !forceCheck) {
|
||||
const laterTime = parseInt(updateLaterTimestamp);
|
||||
const now = Date.now();
|
||||
const twentyFourHours = 24 * 60 * 60 * 1000;
|
||||
|
||||
if (now - laterTime < twentyFourHours) {
|
||||
const sixHours = 6 * 60 * 60 * 1000; // Reduced from 24 hours
|
||||
|
||||
if (now - laterTime < sixHours) {
|
||||
return; // User chose "later" recently
|
||||
}
|
||||
}
|
||||
|
||||
const info = await UpdateService.checkForUpdates();
|
||||
setUpdateInfo(info);
|
||||
|
||||
|
||||
if (info.isAvailable) {
|
||||
setShowUpdatePopup(true);
|
||||
}
|
||||
|
|
@ -131,8 +132,33 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => {
|
|||
}
|
||||
}, [updateInfo.manifest?.id]);
|
||||
|
||||
// Auto-check for updates when hook is first used
|
||||
// Handle startup update check results
|
||||
useEffect(() => {
|
||||
const handleStartupUpdateCheck = (updateInfo: UpdateInfo) => {
|
||||
console.log('UpdatePopup: Received startup update check result', updateInfo);
|
||||
setUpdateInfo(updateInfo);
|
||||
setHasCheckedOnStartup(true);
|
||||
|
||||
if (updateInfo.isAvailable) {
|
||||
setShowUpdatePopup(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Register callback for startup update check
|
||||
UpdateService.onUpdateCheck(handleStartupUpdateCheck);
|
||||
|
||||
// Cleanup callback on unmount
|
||||
return () => {
|
||||
UpdateService.offUpdateCheck(handleStartupUpdateCheck);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-check for updates when hook is first used (fallback if startup check fails)
|
||||
useEffect(() => {
|
||||
if (hasCheckedOnStartup) {
|
||||
return; // Already checked on startup
|
||||
}
|
||||
|
||||
// Add a small delay to ensure the app is fully loaded
|
||||
const timer = setTimeout(() => {
|
||||
(async () => {
|
||||
|
|
@ -140,9 +166,9 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => {
|
|||
const lastCheckTs = await AsyncStorage.getItem(UPDATE_LAST_CHECK_TS_KEY);
|
||||
const last = lastCheckTs ? parseInt(lastCheckTs, 10) : 0;
|
||||
const now = Date.now();
|
||||
const twentyFourHours = 24 * 60 * 60 * 1000;
|
||||
if (now - last < twentyFourHours) {
|
||||
return; // Throttle: only auto-check once per 24h
|
||||
const sixHours = 6 * 60 * 60 * 1000; // Reduced from 24 hours
|
||||
if (now - last < sixHours) {
|
||||
return; // Throttle: only auto-check once per 6h
|
||||
}
|
||||
await checkForUpdates();
|
||||
await AsyncStorage.setItem(UPDATE_LAST_CHECK_TS_KEY, String(now));
|
||||
|
|
@ -150,10 +176,10 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => {
|
|||
// ignore
|
||||
}
|
||||
})();
|
||||
}, 2000); // 2 second delay
|
||||
}, 3000); // Increased delay to 3 seconds to give startup check time
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [checkForUpdates]);
|
||||
}, [checkForUpdates, hasCheckedOnStartup]);
|
||||
|
||||
return {
|
||||
showUpdatePopup,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { VideoPlayerService } from '../services/videoPlayerService';
|
|||
import { useSettings } from '../hooks/useSettings';
|
||||
import QualityBadge from '../components/metadata/QualityBadge';
|
||||
import { logger } from '../utils/logger';
|
||||
import { isMkvStream } from '../utils/mkvDetection';
|
||||
|
||||
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
|
||||
const HDR_ICON = 'https://uxwing.com/wp-content/themes/uxwing/download/video-photography-multimedia/hdr-icon.png';
|
||||
|
|
@ -846,12 +847,7 @@ export const StreamsScreen = () => {
|
|||
let forceVlc = !!options?.forceVlc;
|
||||
try {
|
||||
if (Platform.OS === 'ios' && !forceVlc) {
|
||||
// Check if the actual stream is an MKV file
|
||||
const lowerUri = (stream.url || '').toLowerCase();
|
||||
const contentType = (stream.headers && ((stream.headers as any)['Content-Type'] || (stream.headers as any)['content-type'])) || '';
|
||||
const isMkvByHeader = typeof contentType === 'string' && contentType.includes('matroska');
|
||||
const isMkvByPath = lowerUri.includes('.mkv') || /[?&]ext=mkv\b/.test(lowerUri) || /format=mkv\b/.test(lowerUri) || /container=mkv\b/.test(lowerUri);
|
||||
const isMkvFile = Boolean(isMkvByHeader || isMkvByPath);
|
||||
const isMkvFile = isMkvStream(stream.url, stream.headers);
|
||||
|
||||
// Special case: moviebox should always use AndroidVideoPlayer
|
||||
if (streamProvider === 'moviebox') {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ export interface UpdateInfo {
|
|||
isEmbeddedLaunch?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateCheckCallback = (updateInfo: UpdateInfo) => void;
|
||||
|
||||
export class UpdateService {
|
||||
private static instance: UpdateService;
|
||||
private updateCheckInterval: NodeJS.Timeout | null = null;
|
||||
// Removed automatic periodic checks - only check on app start and manual trigger
|
||||
private logs: string[] = [];
|
||||
private readonly MAX_LOGS = 100; // Keep last 100 logs
|
||||
private updateCheckCallbacks: UpdateCheckCallback[] = [];
|
||||
|
||||
private constructor() {}
|
||||
|
||||
|
|
@ -28,23 +31,31 @@ export class UpdateService {
|
|||
* Add a log entry with timestamp - always log to console for adb logcat visibility
|
||||
*/
|
||||
private addLog(message: string, level: 'INFO' | 'WARN' | 'ERROR' = 'INFO'): void {
|
||||
// Logging disabled intentionally
|
||||
return;
|
||||
const timestamp = new Date().toISOString();
|
||||
const logEntry = `[${timestamp}] [${level}] UpdateService: ${message}`;
|
||||
|
||||
// Keep logs for debugging (limited to prevent memory issues)
|
||||
if (this.logs.length >= this.MAX_LOGS) {
|
||||
this.logs.shift(); // Remove oldest log
|
||||
}
|
||||
this.logs.push(logEntry);
|
||||
|
||||
// Always log to console for visibility
|
||||
console.log(logEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all logs
|
||||
*/
|
||||
public getLogs(): string[] {
|
||||
// Logging disabled - return empty list
|
||||
return [];
|
||||
return [...this.logs];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all logs
|
||||
*/
|
||||
public clearLogs(): void {
|
||||
// Logging disabled - no-op
|
||||
this.addLog('Clearing all logs', 'INFO');
|
||||
this.logs = [];
|
||||
}
|
||||
|
||||
|
|
@ -52,8 +63,40 @@ export class UpdateService {
|
|||
* Add a test log entry (useful for debugging)
|
||||
*/
|
||||
public addTestLog(message: string): void {
|
||||
// Logging disabled - no-op
|
||||
return;
|
||||
this.addLog(`TEST: ${message}`, 'INFO');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback to be notified when update checks complete
|
||||
*/
|
||||
public onUpdateCheck(callback: UpdateCheckCallback): void {
|
||||
this.updateCheckCallbacks.push(callback);
|
||||
this.addLog(`Registered update check callback (${this.updateCheckCallbacks.length} total)`, 'INFO');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister an update check callback
|
||||
*/
|
||||
public offUpdateCheck(callback: UpdateCheckCallback): void {
|
||||
const index = this.updateCheckCallbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
this.updateCheckCallbacks.splice(index, 1);
|
||||
this.addLog(`Unregistered update check callback (${this.updateCheckCallbacks.length} remaining)`, 'INFO');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all registered callbacks about an update check result
|
||||
*/
|
||||
private notifyUpdateCheckCallbacks(updateInfo: UpdateInfo): void {
|
||||
this.addLog(`Notifying ${this.updateCheckCallbacks.length} callback(s) about update check result`, 'INFO');
|
||||
this.updateCheckCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(updateInfo);
|
||||
} catch (error) {
|
||||
this.addLog(`Callback notification failed: ${error instanceof Error ? error.message : String(error)}`, 'ERROR');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -189,15 +232,13 @@ export class UpdateService {
|
|||
this.addLog(`Updates enabled: ${Updates.isEnabled}`, 'INFO');
|
||||
this.addLog(`Runtime version: ${Updates.runtimeVersion || 'unknown'}`, 'INFO');
|
||||
this.addLog(`Update URL: ${this.getUpdateUrl()}`, 'INFO');
|
||||
|
||||
try {
|
||||
// Only log initialization info, don't perform automatic update checks
|
||||
this.addLog('UpdateService initialized - updates will be handled manually via popup', 'INFO');
|
||||
|
||||
try {
|
||||
// Check if we're running in a development environment
|
||||
if (__DEV__) {
|
||||
this.addLog('Running in development mode', 'WARN');
|
||||
this.addLog('UpdateService initialization completed (dev mode)', 'INFO');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if updates are enabled
|
||||
|
|
@ -207,7 +248,30 @@ export class UpdateService {
|
|||
return;
|
||||
}
|
||||
|
||||
this.addLog('Updates are enabled, manual update checks only', 'INFO');
|
||||
this.addLog('Updates are enabled, performing initial update check...', 'INFO');
|
||||
|
||||
// Perform an initial update check on app startup
|
||||
try {
|
||||
const updateInfo = await this.checkForUpdates();
|
||||
this.addLog(`Initial update check completed - Updates available: ${updateInfo.isAvailable}`, 'INFO');
|
||||
|
||||
if (updateInfo.isAvailable) {
|
||||
this.addLog('Update available! The popup will be shown to the user.', 'INFO');
|
||||
} else {
|
||||
this.addLog('No updates available at startup', 'INFO');
|
||||
}
|
||||
|
||||
// Notify registered callbacks about the update check result
|
||||
this.notifyUpdateCheckCallbacks(updateInfo);
|
||||
} catch (checkError) {
|
||||
this.addLog(`Initial update check failed: ${checkError instanceof Error ? checkError.message : String(checkError)}`, 'ERROR');
|
||||
|
||||
// Notify callbacks about the failed check
|
||||
this.notifyUpdateCheckCallbacks({ isAvailable: false });
|
||||
|
||||
// Don't fail initialization if update check fails
|
||||
}
|
||||
|
||||
this.addLog('UpdateService initialization completed successfully', 'INFO');
|
||||
} catch (error) {
|
||||
this.addLog(`Initialization failed: ${error instanceof Error ? error.message : String(error)}`, 'ERROR');
|
||||
|
|
|
|||
169
src/utils/mkvDetection.ts
Normal file
169
src/utils/mkvDetection.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* Enhanced MKV stream detection utility
|
||||
* Provides multiple methods to detect if a video stream is in MKV format
|
||||
*/
|
||||
|
||||
export interface StreamDetectionResult {
|
||||
isMkv: boolean;
|
||||
method: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive MKV stream detection
|
||||
* Uses multiple detection methods for maximum accuracy
|
||||
*/
|
||||
export const detectMkvStream = (streamUri: string, streamHeaders?: Record<string, string>): StreamDetectionResult => {
|
||||
if (!streamUri) {
|
||||
return { isMkv: false, method: 'none', confidence: 'high' };
|
||||
}
|
||||
|
||||
const lowerUri = streamUri.toLowerCase();
|
||||
const contentType = (streamHeaders && (streamHeaders['Content-Type'] || streamHeaders['content-type'])) || '';
|
||||
|
||||
// Method 1: Content-Type header detection (most reliable)
|
||||
if (typeof contentType === 'string') {
|
||||
if (/video\/x-matroska|application\/x-matroska/i.test(contentType)) {
|
||||
return { isMkv: true, method: 'content-type', confidence: 'high' };
|
||||
}
|
||||
if (/matroska/i.test(contentType)) {
|
||||
return { isMkv: true, method: 'content-type', confidence: 'high' };
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: File extension detection
|
||||
const mkvExtensions = ['.mkv', '.mka', '.mks', '.mk3d'];
|
||||
for (const ext of mkvExtensions) {
|
||||
if (lowerUri.includes(ext)) {
|
||||
return { isMkv: true, method: 'extension', confidence: 'high' };
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: URL parameter detection
|
||||
const urlPatterns = [
|
||||
/[?&]ext=mkv\b/,
|
||||
/[?&]format=mkv\b/,
|
||||
/[?&]container=mkv\b/,
|
||||
/[?&]codec=mkv\b/,
|
||||
/[?&]format=matroska\b/,
|
||||
/[?&]type=mkv\b/,
|
||||
/[?&]file_format=mkv\b/
|
||||
];
|
||||
|
||||
for (const pattern of urlPatterns) {
|
||||
if (pattern.test(lowerUri)) {
|
||||
return { isMkv: true, method: 'url-pattern', confidence: 'high' };
|
||||
}
|
||||
}
|
||||
|
||||
// Method 4: Known MKV streaming patterns (medium confidence)
|
||||
const streamingPatterns = [
|
||||
/mkv|matroska/i,
|
||||
/video\/x-matroska/i,
|
||||
/ebml/i, // EBML is the container format MKV uses
|
||||
];
|
||||
|
||||
for (const pattern of streamingPatterns) {
|
||||
if (pattern.test(lowerUri)) {
|
||||
return { isMkv: true, method: 'streaming-pattern', confidence: 'medium' };
|
||||
}
|
||||
}
|
||||
|
||||
// Method 5: Provider-specific patterns (lower confidence)
|
||||
const providerPatterns = [
|
||||
/vidsrc|embedsu|multiembed/i, // Providers that often serve MKV
|
||||
];
|
||||
|
||||
for (const pattern of providerPatterns) {
|
||||
if (pattern.test(lowerUri)) {
|
||||
// These providers often serve MKV, but not guaranteed
|
||||
return { isMkv: true, method: 'provider-pattern', confidence: 'low' };
|
||||
}
|
||||
}
|
||||
|
||||
return { isMkv: false, method: 'none', confidence: 'high' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Async HEAD request detection for MKV
|
||||
* Most reliable method but requires network request
|
||||
*/
|
||||
export const detectMkvViaHeadRequest = async (
|
||||
url: string,
|
||||
headers?: Record<string, string>,
|
||||
timeoutMs: number = 2000
|
||||
): Promise<StreamDetectionResult> => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
headers,
|
||||
signal: controller.signal as any,
|
||||
} as any);
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if (/video\/x-matroska|application\/x-matroska/i.test(contentType)) {
|
||||
return { isMkv: true, method: 'head-request', confidence: 'high' };
|
||||
}
|
||||
|
||||
if (/matroska/i.test(contentType)) {
|
||||
return { isMkv: true, method: 'head-request', confidence: 'high' };
|
||||
}
|
||||
|
||||
return { isMkv: false, method: 'head-request', confidence: 'high' };
|
||||
} catch (error) {
|
||||
return { isMkv: false, method: 'head-request-failed', confidence: 'low' };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Combined detection: fast local detection + optional HEAD request
|
||||
*/
|
||||
export const detectMkvComprehensive = async (
|
||||
streamUri: string,
|
||||
streamHeaders?: Record<string, string>,
|
||||
useHeadRequest: boolean = false,
|
||||
headTimeoutMs: number = 2000
|
||||
): Promise<StreamDetectionResult> => {
|
||||
// First try fast local detection
|
||||
const localResult = detectMkvStream(streamUri, streamHeaders);
|
||||
|
||||
if (localResult.isMkv && localResult.confidence === 'high') {
|
||||
return localResult;
|
||||
}
|
||||
|
||||
// If local detection is inconclusive and HEAD request is enabled, try network detection
|
||||
if (useHeadRequest) {
|
||||
const headResult = await detectMkvViaHeadRequest(streamUri, streamHeaders, headTimeoutMs);
|
||||
|
||||
if (headResult.isMkv || headResult.method === 'head-request') {
|
||||
return headResult;
|
||||
}
|
||||
}
|
||||
|
||||
return localResult;
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple boolean wrapper for backward compatibility
|
||||
*/
|
||||
export const isMkvStream = (streamUri: string, streamHeaders?: Record<string, string>): boolean => {
|
||||
const result = detectMkvStream(streamUri, streamHeaders);
|
||||
|
||||
// Debug logging in development
|
||||
if (__DEV__ && streamUri) {
|
||||
console.log('[MKV Detection]', {
|
||||
uri: streamUri.substring(0, 100) + (streamUri.length > 100 ? '...' : ''),
|
||||
isMkv: result.isMkv,
|
||||
method: result.method,
|
||||
confidence: result.confidence
|
||||
});
|
||||
}
|
||||
|
||||
return result.isMkv;
|
||||
};
|
||||
Loading…
Reference in a new issue