From cb4a4465092d9804b6f656c8151bb28cc39e1f6f Mon Sep 17 00:00:00 2001 From: Tazio Date: Sun, 9 Nov 2025 13:01:32 +0100 Subject: [PATCH] feat: Skip intro and credit These are based on chapters --- lib/client/plex_client.dart | 39 +++++- lib/models/plex_media_info.dart | 25 ++++ .../video_controls/video_controls.dart | 111 +++++++++++++++++- 3 files changed, 167 insertions(+), 8 deletions(-) diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index e533385c..d13d2340 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -1,14 +1,16 @@ import 'dart:convert'; + import 'package:dio/dio.dart'; + import '../config/plex_config.dart'; -import '../models/plex_library.dart'; -import '../models/plex_metadata.dart'; -import '../models/plex_media_info.dart'; import '../models/plex_file_info.dart'; import '../models/plex_filter.dart'; -import '../models/plex_sort.dart'; -import '../models/plex_media_version.dart'; import '../models/plex_hub.dart'; +import '../models/plex_library.dart'; +import '../models/plex_media_info.dart'; +import '../models/plex_media_version.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_sort.dart'; import '../utils/app_logger.dart'; /// Result of testing a connection, including success status and latency @@ -496,6 +498,33 @@ class PlexClient { return []; } + Future> getMarkers(String ratingKey) async { + final response = await _dio.get( + '/library/metadata/$ratingKey', + queryParameters: {'includeChapters': 1}, + ); + + final metadataJson = _getFirstMetadataJson(response); + + if (metadataJson != null && metadataJson['Chapter'] != null) { + final chapterList = metadataJson['Chapter'] as List; + final markerList = [ + chapterList.first, + chapterList[chapterList.length - 2], + ]; + return markerList.map((marker) { + return PlexMarker( + id: marker['id'] as int, + type: marker == metadataJson['Chapter'].first ? "intro" : "credits", + startTimeOffset: marker['startTimeOffset'] as int, + endTimeOffset: marker['endTimeOffset'] as int, + ); + }).toList(); + } + + return []; + } + /// Get detailed media info including chapters and tracks /// [mediaIndex] specifies which Media item to use (defaults to 0 - first version) Future getMediaInfo( diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index b44cdb37..a6c2bfa7 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -146,3 +146,28 @@ class PlexChapter { Duration? get endTime => endTimeOffset != null ? Duration(milliseconds: endTimeOffset!) : null; } + +class PlexMarker { + final int id; + final String type; + final int startTimeOffset; + final int endTimeOffset; + + PlexMarker({ + required this.id, + required this.type, + required this.startTimeOffset, + required this.endTimeOffset, + }); + + Duration get startTime => Duration(milliseconds: startTimeOffset); + Duration get endTime => Duration(milliseconds: endTimeOffset); + + bool get isIntro => type == 'intro'; + bool get isCredits => type == 'credits'; + + bool containsPosition(Duration position) { + final posMs = position.inMilliseconds; + return posMs >= startTimeOffset && posMs <= endTimeOffset; + } +} diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index b462c664..5cf5a1e9 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -1,13 +1,16 @@ import 'dart:async'; import 'dart:io' show Platform; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show SystemChrome, DeviceOrientation; +import 'package:macos_window_utils/macos_window_utils.dart'; import 'package:media_kit/media_kit.dart'; import 'package:window_manager/window_manager.dart'; -import 'package:macos_window_utils/macos_window_utils.dart'; -import '../../models/plex_metadata.dart'; + import '../../models/plex_media_info.dart'; import '../../models/plex_media_version.dart'; +import '../../models/plex_metadata.dart'; +import '../../screens/video_player_screen.dart'; import '../../services/fullscreen_state_manager.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../services/settings_service.dart'; @@ -15,7 +18,6 @@ import '../../services/sleep_timer_service.dart'; import '../../utils/desktop_window_padding.dart'; import '../../utils/platform_detector.dart'; import '../../utils/provider_extensions.dart'; -import '../../screens/video_player_screen.dart'; import '../app_bar_back_button.dart'; import 'painters/chapter_marker_painter.dart'; import 'sheets/audio_track_sheet.dart'; @@ -95,15 +97,21 @@ class _PlexVideoControlsState extends State // Seek throttle state Timer? _seekThrottleTimer; Duration? _pendingSeekPosition; + // Current marker state + PlexMarker? _currentMarker; + List _markers = []; + bool _markersLoaded = false; @override void initState() { super.initState(); _focusNode = FocusNode(); _loadChapters(); + _loadMarkers(); _loadSeekTimes(); _startHideTimer(); _initKeyboardService(); + _listenToPosition(); // Add lifecycle observer to reload settings when app resumes WidgetsBinding.instance.addObserver(this); // Add window listener for tracking fullscreen state (for button icon) @@ -116,6 +124,36 @@ class _PlexVideoControlsState extends State _keyboardService = await KeyboardShortcutsService.getInstance(); } + void _listenToPosition() { + widget.player.stream.position.listen((position) { + if (_markers.isEmpty || !_markersLoaded) { + return; + } + + PlexMarker? foundMarker; + for (final marker in _markers) { + if (marker.containsPosition(position)) { + foundMarker = marker; + break; + } + } + + if (foundMarker != _currentMarker) { + if (mounted) { + setState(() { + _currentMarker = foundMarker; + }); + } + } + }); + } + + void _skipMarker() { + if (_currentMarker != null) { + widget.player.seek(_currentMarker!.endTime); + } + } + Future _loadSeekTimes() async { final settingsService = await SettingsService.getInstance(); if (mounted) { @@ -309,6 +347,21 @@ class _PlexVideoControlsState extends State } } + Future _loadMarkers() async { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; + + final markers = await client.getMarkers(widget.metadata.ratingKey); + + if (mounted) { + setState(() { + _markers = markers; + _markersLoaded = true; + }); + } + } + bool _hasMultipleAudioTracks(Tracks? tracks) { if (tracks == null) return false; final audioTracks = tracks.audio @@ -855,12 +908,64 @@ class _PlexVideoControlsState extends State ), ), ), + // Skip intro/credits button + if (_currentMarker != null) + Positioned( + right: 24, + bottom: isMobile ? 80 : 115, + child: AnimatedOpacity( + opacity: 1.0, + duration: const Duration(milliseconds: 300), + child: _buildSkipMarkerButton(), + ), + ), ], ), ), ); } + Widget _buildSkipMarkerButton() { + final markerType = _currentMarker!.isIntro ? 'Intro' : 'Credits'; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _skipMarker, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Skip $markerType', + style: const TextStyle( + color: Colors.black, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + const Icon(Icons.fast_forward, color: Colors.black, size: 20), + ], + ), + ), + ), + ); + } + Widget _buildMobileLayout() { return Column( children: [