feat: Skip intro and credit

These are based on chapters
This commit is contained in:
Tazio
2025-11-09 13:01:32 +01:00
parent b98374b32f
commit cb4a446509
3 changed files with 167 additions and 8 deletions
+34 -5
View File
@@ -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<List<PlexMarker>> 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<PlexMediaInfo?> getMediaInfo(
+25
View File
@@ -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;
}
}
+108 -3
View File
@@ -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<PlexVideoControls>
// Seek throttle state
Timer? _seekThrottleTimer;
Duration? _pendingSeekPosition;
// Current marker state
PlexMarker? _currentMarker;
List<PlexMarker> _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<PlexVideoControls>
_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<void> _loadSeekTimes() async {
final settingsService = await SettingsService.getInstance();
if (mounted) {
@@ -309,6 +347,21 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
}
}
Future<void> _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<PlexVideoControls>
),
),
),
// 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: [