Files
sync_replay_project/apps/desktop/lib/src/jellyfin/track_prefs.dart
T
foxyzandClaude Opus 5.5 bc98c5c665 Chapters on the scrubber, Skip OP/ED, audio/subtitle menus
- PlaybackController exposes chapters, segments (Jellyfin
  MediaSegments for account holders), tracks, position/duration.
- Own seek bar with chapter diamonds and chapter title on hover,
  seeking through the sync user-seek path.
- Skip OP / Skip ED pills from Intro/Outro segments or chapter names
  (ED skips to its end only); Skip 1:30 fallback for >20 min videos
  without OP info; next-episode pill now starts at the ED.
- PgUp/PgDn chapters, Shift+Right +1:30; Audio and Subtitles menus
  with count badges via the existing track path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:56:41 +03:00

275 lines
9.5 KiB
Dart

import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import 'accounts.dart';
// Per-viewer audio/subtitle carry-over: the last choice is kept per series
// (and as a global fallback) and matched against the next Jellyfin item's
// tracks with core's `matchTrack`. Local only; nothing goes on the wire.
const trackPrefsBoxName = 'track_prefs';
const _keyGlobal = 'global';
/// One audio or subtitle track of the open file as mpv's `track-list`
/// has it ([id] is mpv's `aid` / `sid`; [selected] is mpv's choice).
class MpvTrack {
final int id;
final JellyfinStreamType type;
final String? language;
final String? title;
final String? codec;
final int? channels;
final bool isDefault;
final bool isForced;
final bool isHearingImpaired;
final bool isExternal;
final bool selected;
const MpvTrack({
required this.id,
required this.type,
this.language,
this.title,
this.codec,
this.channels,
this.isDefault = false,
this.isForced = false,
this.isHearingImpaired = false,
this.isExternal = false,
this.selected = false,
});
/// As a stream for [matchTrack]; its index is the mpv id.
JellyfinMediaStream get asStream => JellyfinMediaStream(
index: id,
type: type,
language: language,
title: title,
codec: codec,
channels: channels,
isDefault: isDefault,
isForced: isForced,
isHearingImpaired: isHearingImpaired);
/// Menu text: "Japanese · E-AC3 2.0 · Commentary", "Track 2 · ASS".
String get label => [
languageName(language) ?? 'Track $id',
[?codecName(codec), ?channelLayoutName(channels)].join(' '),
if (title != null && title != languageName(language)) title!,
if (isForced) 'Forced',
if (isHearingImpaired) 'SDH',
].where((p) => p.isNotEmpty).join(' · ');
@override
bool operator ==(Object other) =>
other is MpvTrack &&
other.id == id &&
other.type == type &&
other.language == language &&
other.title == title &&
other.codec == codec &&
other.channels == channels &&
other.isDefault == isDefault &&
other.isForced == isForced &&
other.isHearingImpaired == isHearingImpaired &&
other.isExternal == isExternal &&
other.selected == selected;
@override
int get hashCode => Object.hash(id, type, language, title, codec, channels, isDefault, isForced,
isHearingImpaired, isExternal, selected);
@override
String toString() => 'MpvTrack(${type.name} $id ${selected ? '*' : ''}$label)';
}
const _languages = {
'jpn': 'Japanese', 'ja': 'Japanese', 'eng': 'English', 'en': 'English',
'ger': 'German', 'deu': 'German', 'de': 'German', 'fre': 'French', 'fra': 'French',
'fr': 'French', 'spa': 'Spanish', 'es': 'Spanish', 'ita': 'Italian', 'it': 'Italian',
'por': 'Portuguese', 'pt': 'Portuguese', 'rus': 'Russian', 'ru': 'Russian',
'chi': 'Chinese', 'zho': 'Chinese', 'zh': 'Chinese', 'kor': 'Korean', 'ko': 'Korean',
'ara': 'Arabic', 'ar': 'Arabic', 'pol': 'Polish', 'pl': 'Polish', 'dut': 'Dutch',
'nld': 'Dutch', 'nl': 'Dutch', 'swe': 'Swedish', 'sv': 'Swedish', 'fin': 'Finnish',
'fi': 'Finnish', 'nor': 'Norwegian', 'no': 'Norwegian', 'dan': 'Danish', 'da': 'Danish',
'tur': 'Turkish', 'tr': 'Turkish', 'hin': 'Hindi', 'hi': 'Hindi', 'tha': 'Thai',
'th': 'Thai', 'vie': 'Vietnamese', 'vi': 'Vietnamese', 'ind': 'Indonesian', 'id': 'Indonesian',
'may': 'Malay', 'msa': 'Malay', 'ms': 'Malay', 'heb': 'Hebrew', 'he': 'Hebrew',
'gre': 'Greek', 'ell': 'Greek', 'el': 'Greek', 'cze': 'Czech', 'ces': 'Czech', 'cs': 'Czech',
'hun': 'Hungarian', 'hu': 'Hungarian', 'ukr': 'Ukrainian', 'uk': 'Ukrainian',
'rum': 'Romanian', 'ron': 'Romanian', 'ro': 'Romanian', 'und': 'Undetermined',
};
/// "jpn" -> "Japanese"; other codes upper-cased, null for none.
String? languageName(String? code) {
if (code == null || code.isEmpty) return null;
return _languages[code.toLowerCase()] ?? code.toUpperCase();
}
const _codecs = {
'eac3': 'E-AC3', 'ac3': 'AC3', 'aac': 'AAC', 'flac': 'FLAC', 'opus': 'Opus',
'vorbis': 'Vorbis', 'mp3': 'MP3', 'truehd': 'TrueHD', 'dts': 'DTS', 'mlp': 'MLP',
'alac': 'ALAC', 'ass': 'ASS', 'ssa': 'SSA', 'subrip': 'SRT', 'srt': 'SRT',
'webvtt': 'VTT', 'mov_text': 'TX3G', 'hdmv_pgs_subtitle': 'PGS', 'pgssub': 'PGS',
'dvd_subtitle': 'VobSub', 'dvdsub': 'VobSub', 'dvb_subtitle': 'DVB',
};
/// mpv / Jellyfin codec name for menus: "eac3" -> "E-AC3", "subrip" -> "SRT".
String? codecName(String? codec) {
if (codec == null || codec.isEmpty) return null;
final c = codec.toLowerCase();
if (c.startsWith('pcm')) return 'PCM';
return _codecs[c] ?? codec.toUpperCase();
}
/// Channel count as a layout: 2 -> "2.0", 6 -> "5.1", 8 -> "7.1".
String? channelLayoutName(int? channels) => switch (channels) {
null || <= 0 => null,
1 => '1.0',
2 => '2.0',
3 => '2.1',
6 => '5.1',
7 => '6.1',
8 => '7.1',
final n => '${n}ch',
};
/// A viewer's audio/subtitle choice as traits (indices shift between
/// files). [subtitlesOff] wins over [subtitle].
class TrackChoice {
final TrackPreference? audio;
final TrackPreference? subtitle;
final bool subtitlesOff;
const TrackChoice({this.audio, this.subtitle, this.subtitlesOff = false});
Map<String, Object?> toJson() => {
'audio': ?_prefToJson(audio),
'subtitle': ?_prefToJson(subtitle),
'subtitlesOff': subtitlesOff,
};
static TrackChoice? fromJson(Object? j) {
if (j is! Map) return null;
return TrackChoice(
audio: _prefFromJson(j['audio'], JellyfinStreamType.audio),
subtitle: _prefFromJson(j['subtitle'], JellyfinStreamType.subtitle),
subtitlesOff: j['subtitlesOff'] == true,
);
}
/// The choice behind mpv's [aid] / [sid] (null = none / off): traits from
/// the Jellyfin [source] when this viewer has it (title, channels,
/// forced/HI flags), else from mpv's [tracks].
factory TrackChoice.of({
required int? aid,
required int? sid,
JellyfinMediaSource? source,
List<MpvTrack> tracks = const [],
}) {
TrackPreference? pref(JellyfinStreamType type, int? id) {
if (id == null) return null;
if (source != null) {
final index = source.jellyfinIndexForMpvTrack(type, id);
final s = index == null ? null : source.streamAt(index);
if (s != null) return TrackPreference.fromStream(s);
}
final t = tracks.where((t) => t.type == type && t.id == id).firstOrNull;
return t == null ? null : TrackPreference.fromStream(t.asStream);
}
return TrackChoice(
audio: pref(JellyfinStreamType.audio, aid),
subtitle: pref(JellyfinStreamType.subtitle, sid),
subtitlesOff: sid == null,
);
}
}
Map<String, Object?>? _prefToJson(TrackPreference? p) => p == null
? null
: {
'language': ?p.language,
'title': ?p.title,
'codec': ?p.codec,
'channels': ?p.channels,
if (p.isForced) 'forced': true,
if (p.isHearingImpaired) 'hi': true,
};
TrackPreference? _prefFromJson(Object? j, JellyfinStreamType type) {
if (j is! Map) return null;
String? s(Object? v) => v is String && v.isNotEmpty ? v : null;
return TrackPreference(
type: type,
language: s(j['language']),
title: s(j['title']),
codec: s(j['codec']),
channels: j['channels'] is int ? j['channels'] as int : null,
isForced: j['forced'] == true,
isHearingImpaired: j['hi'] == true,
);
}
/// mpv tracks to select for [choice] on a new item. Matches against the
/// Jellyfin [source]'s streams when this viewer has them (mapped to mpv ids
/// with [JellyfinMediaSource.mpvTrackId]), else against mpv's [tracks].
/// Null fields keep the player's default; [subtitlesOff] turns them off.
({int? aid, int? sid, bool subtitlesOff}) chooseTracks(TrackChoice choice,
{JellyfinMediaSource? source, List<MpvTrack> tracks = const []}) {
int? pick(TrackPreference? pref) {
if (pref == null) return null;
if (source != null) {
final s = matchTrack(pref, source.mediaStreams);
return s == null ? null : source.mpvTrackId(s.index);
}
return matchTrack(pref, [for (final t in tracks) t.asStream])?.index;
}
return (
aid: pick(choice.audio),
sid: choice.subtitlesOff ? null : pick(choice.subtitle),
subtitlesOff: choice.subtitlesOff,
);
}
/// The last choice per series and overall, in their own Hive box.
class TrackPrefs {
TrackPrefs({JellyfinStore? store}) : _store = store ?? HiveJellyfinStore(boxName: trackPrefsBoxName);
final JellyfinStore _store;
Future<void>? _open;
Future<void> _ready() => _open ??= _store.open().catchError((Object e) {
debugPrint('open track prefs failed: $e');
});
/// The series' choice, else the global one.
Future<TrackChoice?> load({String? seriesId}) async {
await _ready();
try {
Object? read(String key) {
final v = _store.get(key);
return v is String ? jsonDecode(v) : null;
}
return (seriesId == null ? null : TrackChoice.fromJson(read('series:$seriesId'))) ??
TrackChoice.fromJson(read(_keyGlobal));
} catch (e) {
debugPrint('read track prefs failed: $e');
return null;
}
}
/// Saves [choice] as the global one and, with [seriesId], the series'.
Future<void> save(TrackChoice choice, {String? seriesId}) async {
await _ready();
try {
final v = jsonEncode(choice.toJson());
await _store.put(_keyGlobal, v);
if (seriesId != null) await _store.put('series:$seriesId', v);
} catch (e) {
debugPrint('save track prefs failed: $e');
}
}
}