From cb4a4465092d9804b6f656c8151bb28cc39e1f6f Mon Sep 17 00:00:00 2001 From: Tazio Date: Sun, 9 Nov 2025 13:01:32 +0100 Subject: [PATCH 1/3] 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: [ From 15dd8f813a03048b55aef855724d2c1951f751a6 Mon Sep 17 00:00:00 2001 From: Tazio Date: Sun, 9 Nov 2025 13:14:01 +0100 Subject: [PATCH 2/3] fix: change skip credit to Next Episode convenience is king --- lib/client/plex_client.dart | 5 +- lib/screens/video_player_screen.dart | 73 +++++++++++-------- .../video_controls/video_controls.dart | 18 ++++- 3 files changed, 58 insertions(+), 38 deletions(-) diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index d13d2340..0ebed970 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -508,10 +508,7 @@ class PlexClient { if (metadataJson != null && metadataJson['Chapter'] != null) { final chapterList = metadataJson['Chapter'] as List; - final markerList = [ - chapterList.first, - chapterList[chapterList.length - 2], - ]; + final markerList = [chapterList.first, chapterList.last]; return markerList.map((marker) { return PlexMarker( id: marker['id'] as int, diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 36612372..00e608ae 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1,24 +1,26 @@ import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; -import 'package:provider/provider.dart'; import 'package:os_media_controls/os_media_controls.dart'; +import 'package:provider/provider.dart'; + +import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; -import '../providers/plex_client_provider.dart'; import '../providers/playback_state_provider.dart'; +import '../providers/plex_client_provider.dart'; import '../providers/settings_provider.dart'; -import '../utils/provider_extensions.dart'; -import '../widgets/video_controls/video_controls.dart'; -import '../utils/language_codes.dart'; -import '../utils/app_logger.dart'; import '../services/settings_service.dart'; +import '../utils/app_logger.dart'; +import '../utils/language_codes.dart'; import '../utils/orientation_helper.dart'; -import '../utils/video_player_navigation.dart'; import '../utils/platform_detector.dart'; -import '../models/plex_media_version.dart'; +import '../utils/provider_extensions.dart'; +import '../utils/video_player_navigation.dart'; +import '../widgets/video_controls/video_controls.dart'; class VideoPlayerScreen extends StatefulWidget { final PlexMetadata metadata; @@ -240,7 +242,6 @@ class VideoPlayerScreenState extends State { } } - Future _loadAdjacentEpisodes() async { if (widget.metadata.type.toLowerCase() != 'episode') { return; @@ -269,7 +270,9 @@ class VideoPlayerScreenState extends State { widget.metadata.ratingKey, loopQueue: loopQueue, ); - previous = playbackState.getPreviousEpisode(widget.metadata.ratingKey); + previous = playbackState.getPreviousEpisode( + widget.metadata.ratingKey, + ); } else { // Use chronological order even in shuffle mode next = await client.findAdjacentEpisode(widget.metadata, 1); @@ -1204,7 +1207,9 @@ class VideoPlayerScreenState extends State { // Get artwork URL String? artworkUrl; if (client == null) { - appLogger.w('Cannot get artwork URL for media controls: Plex client is null'); + appLogger.w( + 'Cannot get artwork URL for media controls: Plex client is null', + ); } else { final thumbUrl = metadata.type.toLowerCase() == 'episode' ? metadata.grandparentThumb ?? metadata.thumb @@ -1235,15 +1240,17 @@ class VideoPlayerScreenState extends State { } } - await OsMediaControls.setMetadata(MediaMetadata( - title: title, - artist: artist, - album: album, - duration: metadata.duration != null - ? Duration(milliseconds: metadata.duration!) - : null, - artworkUrl: artworkUrl, - )); + await OsMediaControls.setMetadata( + MediaMetadata( + title: title, + artist: artist, + album: album, + duration: metadata.duration != null + ? Duration(milliseconds: metadata.duration!) + : null, + artworkUrl: artworkUrl, + ), + ); // Set initial playback state _updateMediaControlsPlaybackState(); @@ -1252,11 +1259,15 @@ class VideoPlayerScreenState extends State { void _updateMediaControlsPlaybackState() { if (player == null) return; - OsMediaControls.setPlaybackState(MediaPlaybackState( - state: player!.state.playing ? PlaybackState.playing : PlaybackState.paused, - position: player!.state.position, - speed: player!.state.rate, - )); + OsMediaControls.setPlaybackState( + MediaPlaybackState( + state: player!.state.playing + ? PlaybackState.playing + : PlaybackState.paused, + position: player!.state.position, + speed: player!.state.rate, + ), + ); } void _updateMediaControlsPosition() { @@ -1264,11 +1275,13 @@ class VideoPlayerScreenState extends State { // Only update if playing to avoid excessive updates if (player!.state.playing) { - OsMediaControls.setPlaybackState(MediaPlaybackState( - state: PlaybackState.playing, - position: player!.state.position, - speed: player!.state.rate, - )); + OsMediaControls.setPlaybackState( + MediaPlaybackState( + state: PlaybackState.playing, + position: player!.state.position, + speed: player!.state.rate, + ), + ); } } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 5cf5a1e9..e7a628e8 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -926,12 +926,22 @@ class _PlexVideoControlsState extends State } Widget _buildSkipMarkerButton() { - final markerType = _currentMarker!.isIntro ? 'Intro' : 'Credits'; + final isCredits = _currentMarker!.isCredits; + final hasNextEpisode = widget.onNext != null; + + // Show "Next Episode" for credits when next episode is available + final bool showNextEpisode = isCredits && hasNextEpisode; + final String buttonText = showNextEpisode + ? 'Next Episode' + : (isCredits ? 'Skip Credits' : 'Skip Intro'); + final IconData buttonIcon = showNextEpisode + ? Icons.skip_next + : Icons.fast_forward; return Material( color: Colors.transparent, child: InkWell( - onTap: _skipMarker, + onTap: showNextEpisode ? widget.onNext : _skipMarker, borderRadius: BorderRadius.circular(8), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), @@ -950,7 +960,7 @@ class _PlexVideoControlsState extends State mainAxisSize: MainAxisSize.min, children: [ Text( - 'Skip $markerType', + buttonText, style: const TextStyle( color: Colors.black, fontSize: 16, @@ -958,7 +968,7 @@ class _PlexVideoControlsState extends State ), ), const SizedBox(width: 8), - const Icon(Icons.fast_forward, color: Colors.black, size: 20), + Icon(buttonIcon, color: Colors.black, size: 20), ], ), ), From 349b28d25e4296c7ed0a94198de20849e6369928 Mon Sep 17 00:00:00 2001 From: Tazio Date: Sun, 9 Nov 2025 14:36:01 +0100 Subject: [PATCH 3/3] refactor: apparently markers do exist I swear they weren't in the documentation --- lib/client/plex_client.dart | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/client/plex_client.dart b/lib/client/plex_client.dart index 0ebed970..65b9e324 100644 --- a/lib/client/plex_client.dart +++ b/lib/client/plex_client.dart @@ -501,18 +501,17 @@ class PlexClient { Future> getMarkers(String ratingKey) async { final response = await _dio.get( '/library/metadata/$ratingKey', - queryParameters: {'includeChapters': 1}, + queryParameters: {'includeMarkers': 1}, ); final metadataJson = _getFirstMetadataJson(response); - if (metadataJson != null && metadataJson['Chapter'] != null) { - final chapterList = metadataJson['Chapter'] as List; - final markerList = [chapterList.first, chapterList.last]; + if (metadataJson != null && metadataJson['Marker'] != null) { + final markerList = metadataJson['Marker'] as List; return markerList.map((marker) { return PlexMarker( id: marker['id'] as int, - type: marker == metadataJson['Chapter'].first ? "intro" : "credits", + type: marker['type'] as String, startTimeOffset: marker['startTimeOffset'] as int, endTimeOffset: marker['endTimeOffset'] as int, );